diff --git a/.eslint-plugin-local/code-no-telemetry-common-property.ts b/.eslint-plugin-local/code-no-telemetry-common-property.ts index c6e62831f8d77..5634227972b47 100644 --- a/.eslint-plugin-local/code-no-telemetry-common-property.ts +++ b/.eslint-plugin-local/code-no-telemetry-common-property.ts @@ -49,6 +49,7 @@ const commonTelemetryProperties = new Set([ 'common.copilottrackingid', 'common.copilotsdkversion', 'common.copilotruntimeversion', + 'copilotsku', 'common.isagentswindow', ]); diff --git a/build/azure-pipelines/product-build-ado-ci.yml b/build/azure-pipelines/product-build-ado-ci.yml index d719b96f7a094..b2c62d5a7154d 100644 --- a/build/azure-pipelines/product-build-ado-ci.yml +++ b/build/azure-pipelines/product-build-ado-ci.yml @@ -297,7 +297,7 @@ extends: if [ "${#trailer_values[@]}" = "0" ]; then echo "##vso[task.logissue type=error]Commit $head_sha is missing the required 'Msrc-Case-Id' trailer." - printf "Add a trailer to the commit message, for example:\n\n Msrc-Case-Id: 12345\n\n" + printf "Add a trailer to the commit message, for example:\n\n Msrc-Case-Id: 12345\n\nIf there is no associated case ID, use N/A:\n\n Msrc-Case-Id: N/A\n\n" exit 1 fi diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 6a409588d0980..1962a937ec3ec 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -38,6 +38,21 @@ } ], "policies": [ + { + "key": "agents.voice.enabled", + "name": "AgentsVoice", + "category": "InteractiveSession", + "minimumVersion": "1.137", + "localization": { + "description": { + "key": "agents.voice.enabled", + "value": "Enable the Voice Mode panel in the chat view for voice-driven coding conversations." + } + }, + "type": "boolean", + "default": false, + "included": true + }, { "key": "chat.agent.allowedNetworkDomains", "name": "ChatAgentAllowedNetworkDomains", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5eba41442252a..c83a04b76d13d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -5242,16 +5242,6 @@ "onExp" ] }, - "github.copilot.chat.agentDebugLog.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%github.copilot.config.chat.agentDebugLog.enabled%", - "deprecationMessage": "%github.copilot.config.chat.agentDebugLog.enabled.deprecated%", - "tags": [ - "advanced", - "experimental" - ] - }, "github.copilot.chat.agentDebugLog.fileLogging.enabled": { "type": "boolean", "default": false, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 0839456250b8a..8165b301befd7 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -390,8 +390,6 @@ "github.copilot.config.inlineChat.reasoningEffort": "Controls the reasoning effort level for inline chat requests. Lower values result in faster responses with fewer reasoning tokens. Supported values depend on the model.", "github.copilot.config.inlineChat.enableThinking": "Controls whether thinking/reasoning is enabled for inline chat requests. When disabled, reasoning summaries are suppressed for faster responses.", "github.copilot.config.debug.requestLogger.maxEntries": "Maximum number of entries to keep in the request logger for debugging purposes.", - "github.copilot.config.chat.agentDebugLog.enabled": "Deprecated: use `github.copilot.chat.agentDebugLog.fileLogging.enabled` instead.", - "github.copilot.config.chat.agentDebugLog.enabled.deprecated": "This setting has been merged into `github.copilot.chat.agentDebugLog.fileLogging.enabled`. Please use this setting instead.", "github.copilot.config.chat.agentDebugLog.fileLogging.enabled": "Enable agent debug logging: write chat debug events (tool calls, LLM requests, token usage, errors) to JSONL files for the debug panel and troubleshoot skill. Requires window reload to take effect.", "github.copilot.config.chat.agentDebugLog.fileLogging.flushIntervalMs": "How often (in milliseconds) buffered debug log entries are flushed to disk. Lower values provide more up-to-date logs at the cost of more frequent disk writes.", "github.copilot.config.chat.agentDebugLog.fileLogging.maxRetainedSessionLogs": "Maximum number of chat debug session log directories to retain on disk. Each chat session produces one directory. Older session logs are automatically deleted when this limit is exceeded.", diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 9cd52e957eca5..20c28c726c500 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -846,9 +846,7 @@ export namespace ConfigKey { /** Simulate GitHub authentication failures for testing. Can't be TeamInternal because we lose these flags as part of testing. */ export const DebugGitHubAuthFailWith = defineSetting<'NotAuthorized' | 'RequestFailed' | 'ParseFailed' | 'HTTP401' | 'RateLimited' | 'GitHubLoginFailed' | null>('chat.debug.githubAuthFailWith', ConfigType.Simple, null); - // Agent debug logging settings — fileLogging.enabled is the canonical toggle - /** @deprecated Use ChatDebugFileLogging instead. Kept during experiment transition. */ - export const AgentDebugLogEnabled = defineAndMigrateExpSetting('agentDebugLog.enabled', 'chat.agentDebugLog.enabled', false); + // Agent debug logging settings export const ChatDebugFileLogging = defineAndMigrateExpSetting('chat.chatDebug.fileLogging.enabled', 'chat.agentDebugLog.fileLogging.enabled', false); export const ChatDebugFileLoggingFlushInterval = defineAndMigrateSetting('chat.chatDebug.fileLogging.flushIntervalMs', 'chat.agentDebugLog.fileLogging.flushIntervalMs', 4000); export const ChatDebugFileLoggingMaxRetainedSessionLogs = defineSetting('chat.agentDebugLog.fileLogging.maxRetainedSessionLogs', ConfigType.ExperimentBased, 50); diff --git a/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts index 1bb0277c9aafc..ba23d46ee35eb 100644 --- a/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts +++ b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts @@ -40,10 +40,11 @@ const retiredAutoModeTiers: Readonly> = { /** * Maps a retired tier name to its current one, leaving anything else untouched. - * Only raw inputs that bypass the picker schema need this, notably the override setting. + * Needed for raw inputs that predate the rename, such as the override setting or a persisted picker + * value restored before its model's schema has loaded. */ -export function normalizeAutoModeTier(value: string): string { - return retiredAutoModeTiers[value] ?? value; +export function normalizeAutoModeTier(value: unknown): unknown { + return typeof value === 'string' ? retiredAutoModeTiers[value] ?? value : value; } /** diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 9df4793499701..77ec550083470 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -404,11 +404,9 @@ export class AutomodeService extends Disposable implements IAutomodeService { private _resolveTier(chatRequest: IAutoModeRoutingRequest | undefined): AutoModeTier | undefined { const override = this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTierOverride); if (override) { - // Normalized because the override is a raw string setting, so unlike a picker value it never - // passes through the schema filter that drops retired names. const normalized = normalizeAutoModeTier(override); // The override is internal, so unlike the picker it may select `fast`. - if ((autoModeTiers as readonly string[]).includes(normalized)) { + if (autoModeTiers.some(tier => tier === normalized)) { return normalized as AutoModeTier; } this._logService.warn(`[AutomodeService] Ignoring auto tier override '${override}' — not one of [${autoModeTiers.join(', ')}].`); @@ -416,7 +414,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { if (!this.areAutoModeTiersSupported()) { return undefined; } - const configured = chatRequest?.modelConfiguration?.[AUTO_MODE_TIER_PROPERTY]; + const configured = normalizeAutoModeTier(chatRequest?.modelConfiguration?.[AUTO_MODE_TIER_PROPERTY]); if (isSelectableAutoModeTier(configured) && configured !== defaultAutoModeTier) { return configured; } diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index 23fce85c0d158..e43a3a6003cb9 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -870,6 +870,32 @@ describe('AutomodeService', () => { expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'efficiency' }]); }); + + // A picker value stored before the rename can be restored unfiltered while its model's + // schema is still loading, so it must upgrade rather than silently fall back. + it('maps a retired tier name in a persisted picker value to its current one', async () => { + enableTiers(); + const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); + mockAuto(autoResponse('gpt-4o')); + + automodeService = createService(); + await automodeService.resolveAutoModeEndpoint({ + location: ChatLocation.Panel, + prompt: 'panel turn', + sessionId: 'session-persisted-retired', + modelConfiguration: { tier: 'max' }, + } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]); + // `balanced` upgrades to the current default, which reads as "never picked", so the inline + // pin applies instead of being treated as an explicit selection. + await automodeService.resolveAutoModeEndpoint({ + location: ChatLocation.Editor, + prompt: 'inline turn', + sessionId: 'session-persisted-retired-default', + modelConfiguration: { tier: 'balanced' }, + } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]); + + expect(autoRequestBodies().map(b => b.tier)).toEqual(['intelligence', 'fast']); + }); }); describe('session cache', () => { diff --git a/extensions/copilot/src/platform/networking/node/fetcherFallback.ts b/extensions/copilot/src/platform/networking/node/fetcherFallback.ts index 745dac6427909..18001d41ebc52 100644 --- a/extensions/copilot/src/platform/networking/node/fetcherFallback.ts +++ b/extensions/copilot/src/platform/networking/node/fetcherFallback.ts @@ -20,6 +20,7 @@ const fetcherConfigKeys: Partial>> = { const terminalResponseStatusCodes = new Set([429, 502, 503]); const allFetchersFailedTelemetryIntervalMs = 15 * 60 * 1000; const maxAggregatedErrorLength = 1024; +const maxAggregatedErrorCount = 100; const maxAggregatedErrorsSerializedLength = 8192; const aggregatedErrorsOverflowKey = ''; const allFetchersFailedErrors = new Map(); @@ -85,7 +86,12 @@ function recordErrors(target: Map, errors: readonly string[]): v target.set(truncatedError, Math.min(count + 1, Number.MAX_SAFE_INTEGER)); continue; } - target.set(truncatedError, 1); + if (target.size < maxAggregatedErrorCount) { + target.set(truncatedError, 1); + continue; + } + const overflowCount = target.get(aggregatedErrorsOverflowKey) ?? 0; + target.set(aggregatedErrorsOverflowKey, Math.min(overflowCount + 1, Number.MAX_SAFE_INTEGER)); } } diff --git a/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts b/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts index 50d94fba8ffeb..1e6a420975f6a 100644 --- a/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts +++ b/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts @@ -296,6 +296,66 @@ suite('FetcherFallback Test Suite', function () { } }); + test('caps terminal response error cardinality while preserving counts', async function () { + vi.useFakeTimers(); + cleanupTime += 24 * 60 * 60 * 1000; + vi.setSystemTime(cleanupTime); + const primingTelemetryService = new SpyingTelemetryService(); + const primingFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(503, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(primingFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + vi.advanceTimersByTime(16 * 60 * 1000); + const createSuccessfulFetchers = () => createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + const spyingTelemetryService = new SpyingTelemetryService(); + try { + for (let i = 0; i < 200; i++) { + const testFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(503, someJSON, `unique status ${i}`) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(testFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + } + const repeatedOverflowFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(503, someJSON, 'unique status 199') }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(repeatedOverflowFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + + vi.advanceTimersByTime(15 * 60 * 1000 + 1); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + const telemetryEvents = spyingTelemetryService.getEvents().telemetryServiceEvents; + const properties = telemetryEvents[0].properties; + if (!properties || !('errors' in properties) || typeof properties.errors !== 'string') { + assert.fail('Expected an errors telemetry property'); + } + const errors: Record = JSON.parse(properties.errors); + + assert.deepStrictEqual({ + eventCount: telemetryEvents.length, + eventName: telemetryEvents[0].eventName, + errorKeyCount: Object.keys(errors).length, + overflowCount: errors[''], + repeatedOverflowErrorCount: errors['fetcher1: 503 unique status 199'], + allFailuresAccountedFor: Object.values(errors).reduce((total, count) => total + count, 0), + }, { + eventCount: 1, + eventName: 'fetcherTerminalResponse', + errorKeyCount: 101, + overflowCount: 101, + repeatedOverflowErrorCount: undefined, + allFailuresAccountedFor: 201, + }); + } finally { + vi.useRealTimers(); + } + }); + test('no fetcher succeeds', async function () { const fetcherSpec = [ { name: 'fetcher1', response: createFakeResponse(407, someHTML) }, @@ -407,7 +467,7 @@ function createTestFetchers(fetcherSpecs: Array<{ name: string; response: Respon } return next; }, - fetchWithPagination: async (baseUrl: string, options: PaginationOptions): Promise => { + fetchWithPagination: async (_baseUrl: string, _options: PaginationOptions): Promise => { throw new Error('Method not implemented.'); }, disconnectAll: async () => { }, @@ -422,10 +482,10 @@ function createTestFetchers(fetcherSpecs: Array<{ name: string; response: Respon return { fetchers, calls }; } -function createFakeResponse(statusCode: number, content: string) { +function createFakeResponse(statusCode: number, content: string, statusText = 'status text') { return Response.fromText( statusCode, - 'status text', + statusText, new FakeHeaders(), content, 'test-stub' diff --git a/extensions/html-language-features/package.json b/extensions/html-language-features/package.json index 1ab47fa07ca5a..c84dc71018c88 100644 --- a/extensions/html-language-features/package.json +++ b/extensions/html-language-features/package.json @@ -224,13 +224,6 @@ "default": true, "description": "%html.hover.references%" }, - "html.mirrorCursorOnMatchingTag": { - "type": "boolean", - "scope": "resource", - "default": false, - "description": "%html.mirrorCursorOnMatchingTag%", - "deprecationMessage": "%html.mirrorCursorOnMatchingTagDeprecationMessage%" - }, "html.trace.server": { "type": "string", "scope": "window", diff --git a/extensions/html-language-features/package.nls.json b/extensions/html-language-features/package.nls.json index d8390703757af..21bbbad912d4b 100644 --- a/extensions/html-language-features/package.nls.json +++ b/extensions/html-language-features/package.nls.json @@ -33,8 +33,6 @@ "html.completion.attributeDefaultValue.doublequotes": "Attribute value is set to \"\".", "html.completion.attributeDefaultValue.singlequotes": "Attribute value is set to ''.", "html.completion.attributeDefaultValue.empty": "Attribute value is not set.", - "html.mirrorCursorOnMatchingTag": "Enable/disable mirroring cursor on matching HTML tag.", - "html.mirrorCursorOnMatchingTagDeprecationMessage": "Deprecated in favor of `editor.linkedEditing`", "html.hover.documentation": "Show tag and attribute documentation in hover.", "html.hover.references": "Show references to MDN in hover." } diff --git a/extensions/npm/README.md b/extensions/npm/README.md index d5538706019be..6257885ff50d9 100644 --- a/extensions/npm/README.md +++ b/extensions/npm/README.md @@ -15,7 +15,7 @@ For more information about auto detection of Tasks, see the [documentation](http ### Script Explorer -The Npm Script Explorer shows the npm scripts found in your workspace. The explorer view is enabled by the setting `npm.enableScriptExplorer`. A script can be opened, run, or debug from the explorer. +The Npm Script Explorer shows the npm scripts found in your workspace. A script can be opened, run, or debug from the explorer. ### Run Scripts from the Editor @@ -37,7 +37,6 @@ The extension fetches data from and { + if (request.url === '/set-global') { + response.setHeader('Set-Cookie', `vscode-browser-global-smoke=${token}; Path=/; SameSite=Lax`); + response.end('global-cookie-set'); + return; + } + + if (request.url === '/set-workspace') { + response.setHeader('Set-Cookie', `vscode-browser-workspace-smoke=${token}; Path=/; SameSite=Lax`); + response.end('workspace-cookie-set'); + return; + } + + if (request.url === '/set-agent') { + response.setHeader('Set-Cookie', `vscode-browser-agent-smoke=${token}; Path=/; SameSite=Lax`); + response.end('agent-cookie-set'); + return; + } + + if (request.url === '/check-agent') { + agentReceivedCookie = request.headers.cookie; + response.end('agent-cookie-checked'); + return; + } + + if (request.url === '/check-global') { + globalReceivedCookie = request.headers.cookie; + response.end('global-cookie-checked'); + return; + } + + if (request.url === '/check-workspace') { + workspaceReceivedCookie = request.headers.cookie; + response.end('workspace-cookie-checked'); + return; + } + + response.end('unexpected-request'); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const browserConfig = vscode.workspace.getConfiguration('workbench.browser'); + + try { + await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); + const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-global`); + for (let i = 0; i < 100 && !globalSetTab.title.startsWith('global-cookie-set'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(globalSetTab.title.startsWith('global-cookie-set'), `Expected Global page to load, got title "${globalSetTab.title}"`); + + await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); + const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-workspace`); + for (let i = 0; i < 100 && !workspaceSetTab.title.startsWith('workspace-cookie-set'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(workspaceSetTab.title.startsWith('workspace-cookie-set'), `Expected Workspace page to load, got title "${workspaceSetTab.title}"`); + + await browserConfig.update('dataStorage', 'agent', vscode.ConfigurationTarget.Global); + const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-agent`); + + for (let i = 0; i < 100 && !agentSetTab.title.startsWith('agent-cookie-set'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(agentSetTab.title.startsWith('agent-cookie-set'), `Expected Agent page to load, got title "${agentSetTab.title}"`); + + const output = await invokeTool('open_browser_page', { + url: `http://127.0.0.1:${address.port}/check-agent`, + forceNew: true, + }); + + await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); + const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-global`); + for (let i = 0; i < 100 && !globalCheckTab.title.startsWith('global-cookie-checked'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + + await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); + const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-workspace`); + for (let i = 0; i < 100 && !workspaceCheckTab.title.startsWith('workspace-cookie-checked'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + + assert.deepStrictEqual({ + opened: /Page ID:/.test(output), + agentSharedCookie: agentReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + agentReceivedGlobalCookie: agentReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, + agentReceivedWorkspaceCookie: agentReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, + globalLoaded: globalCheckTab.title.startsWith('global-cookie-checked'), + globalSharedCookie: globalReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, + globalReceivedAgentCookie: globalReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + workspaceLoaded: workspaceCheckTab.title.startsWith('workspace-cookie-checked'), + workspaceSharedCookie: workspaceReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, + workspaceReceivedAgentCookie: workspaceReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + }, { + opened: true, + agentSharedCookie: true, + agentReceivedGlobalCookie: false, + agentReceivedWorkspaceCookie: false, + globalLoaded: true, + globalSharedCookie: true, + globalReceivedAgentCookie: false, + workspaceLoaded: true, + workspaceSharedCookie: true, + workspaceReceivedAgentCookie: false, + }); + } finally { + await browserConfig.update('dataStorage', undefined, vscode.ConfigurationTarget.Global); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + }); + test('list_browser_pages returns pages opened through the browser tools', async function () { this.timeout(60000); diff --git a/package-lock.json b/package-lock.json index 6705f4a27b5e4..0a585ac9ad0c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1155,9 +1155,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82.tgz", - "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", + "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -1166,20 +1166,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82", - "@github/copilot-darwin-x64": "1.0.82", - "@github/copilot-linux-arm64": "1.0.82", - "@github/copilot-linux-x64": "1.0.82", - "@github/copilot-linuxmusl-arm64": "1.0.82", - "@github/copilot-linuxmusl-x64": "1.0.82", - "@github/copilot-win32-arm64": "1.0.82", - "@github/copilot-win32-x64": "1.0.82" + "@github/copilot-darwin-arm64": "1.0.83-0", + "@github/copilot-darwin-x64": "1.0.83-0", + "@github/copilot-linux-arm64": "1.0.83-0", + "@github/copilot-linux-x64": "1.0.83-0", + "@github/copilot-linuxmusl-arm64": "1.0.83-0", + "@github/copilot-linuxmusl-x64": "1.0.83-0", + "@github/copilot-win32-arm64": "1.0.83-0", + "@github/copilot-win32-x64": "1.0.83-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82.tgz", - "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", + "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", "cpu": [ "arm64" ], @@ -1193,9 +1193,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82.tgz", - "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", + "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", "cpu": [ "x64" ], @@ -1209,9 +1209,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82.tgz", - "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", + "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", "cpu": [ "arm64" ], @@ -1228,9 +1228,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82.tgz", - "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", + "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", "cpu": [ "x64" ], @@ -1247,9 +1247,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82.tgz", - "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", + "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", "cpu": [ "arm64" ], @@ -1266,9 +1266,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82.tgz", - "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", + "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", "cpu": [ "x64" ], @@ -1285,12 +1285,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.13-preview.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.2.tgz", - "integrity": "sha512-hdMEfghZszu5sgbjZLF1aroLT6WrCk3M8W1Kfd5G5gHLe6QQ8qG8EJaTU+ITw4FN5J25k6IsbhkUO7NqSXeTIQ==", + "version": "1.0.13-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.4.tgz", + "integrity": "sha512-v8UDdtcEC/T8SfF3S0PTQi+1LtNS+7aPba+iYKDtvCXpUaoZHh66r+DZhIJxI7R+J9NESSxvF+kr8lWQoad5+Q==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.83-0", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -1300,9 +1300,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82.tgz", - "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", + "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", "cpu": [ "arm64" ], @@ -1316,9 +1316,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82.tgz", - "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", + "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 1f7f302ef5330..46547033cb9b6 100644 --- a/package.json +++ b/package.json @@ -101,8 +101,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/remote/package-lock.json b/remote/package-lock.json index c24cdb237b936..a61dff0c49c32 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,8 +8,8 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.8.0", @@ -61,9 +61,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82.tgz", - "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", + "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -72,20 +72,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82", - "@github/copilot-darwin-x64": "1.0.82", - "@github/copilot-linux-arm64": "1.0.82", - "@github/copilot-linux-x64": "1.0.82", - "@github/copilot-linuxmusl-arm64": "1.0.82", - "@github/copilot-linuxmusl-x64": "1.0.82", - "@github/copilot-win32-arm64": "1.0.82", - "@github/copilot-win32-x64": "1.0.82" + "@github/copilot-darwin-arm64": "1.0.83-0", + "@github/copilot-darwin-x64": "1.0.83-0", + "@github/copilot-linux-arm64": "1.0.83-0", + "@github/copilot-linux-x64": "1.0.83-0", + "@github/copilot-linuxmusl-arm64": "1.0.83-0", + "@github/copilot-linuxmusl-x64": "1.0.83-0", + "@github/copilot-win32-arm64": "1.0.83-0", + "@github/copilot-win32-x64": "1.0.83-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82.tgz", - "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", + "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82.tgz", - "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", + "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82.tgz", - "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", + "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82.tgz", - "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", + "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", "cpu": [ "x64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82.tgz", - "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", + "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", "cpu": [ "arm64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82.tgz", - "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", + "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", "cpu": [ "x64" ], @@ -191,12 +191,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.13-preview.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.2.tgz", - "integrity": "sha512-hdMEfghZszu5sgbjZLF1aroLT6WrCk3M8W1Kfd5G5gHLe6QQ8qG8EJaTU+ITw4FN5J25k6IsbhkUO7NqSXeTIQ==", + "version": "1.0.13-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.4.tgz", + "integrity": "sha512-v8UDdtcEC/T8SfF3S0PTQi+1LtNS+7aPba+iYKDtvCXpUaoZHh66r+DZhIJxI7R+J9NESSxvF+kr8lWQoad5+Q==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.83-0", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -206,9 +206,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82.tgz", - "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", + "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", "cpu": [ "arm64" ], @@ -222,9 +222,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82.tgz", - "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", + "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index f8ce30a06757a..af7d55252059c 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.8.0", diff --git a/src/vs/base/browser/browser.ts b/src/vs/base/browser/browser.ts index b6e9ec09fff6f..7b6cd63133cbd 100644 --- a/src/vs/base/browser/browser.ts +++ b/src/vs/base/browser/browser.ts @@ -5,6 +5,7 @@ import { CodeWindow, mainWindow } from './window.js'; import { Emitter } from '../common/event.js'; +import { isIOS } from '../common/platform.js'; class WindowManager { @@ -127,6 +128,18 @@ export function isStandalone(): boolean { return standalone; } +/** + * Whether we are an installed web app (PWA) on a phone or tablet. + * + * These have no browser tab to fall back on, so anything a tab could recover from + * — a blocked popup, a navigation that lands somewhere unexpected — is a dead end + * here. Desktop installed apps still get real windows and are deliberately not + * included. + */ +export function isMobileStandalone(): boolean { + return isStandalone() && (isIOS || isAndroid); +} + // Visible means that the feature is enabled, not necessarily being rendered // e.g. visible is true even in fullscreen mode where the controls are hidden // See docs at https://developer.mozilla.org/en-US/docs/Web/API/WindowControlsOverlay/visible diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 4a9cffc80f757..ac3f08fdc23bf 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1637,6 +1637,85 @@ export function windowOpenPopup(url: string): void { ); } +let reservedExternalWindow: Window | undefined; + +function isUsable(candidate: Window | undefined): candidate is Window { + return !!candidate && !candidate.closed; +} + +/** + * Opens a blank window now, for a later {@link windowOpenWithSuccess} to navigate. + * + * Browsers only allow `window.open` while a click's user activation is still live. + * Sign-in shows a dialog, activates an extension and fetches an authorization URL + * first, so by then the gesture has expired and the window is refused — fatal in an + * installed web app (PWA), where there is no tab to fall back to. + * + * Callers must consume or {@link releaseReservedWindowForExternalOpen} the + * reservation, or a blank window is left covering the app. + * + * @param targetWindow the window that was clicked; activation belongs to it. + * @param placeholder already-translated text, since `vs/base` cannot localize. + */ +export function reserveWindowForExternalOpen(targetWindow: Window = mainWindow, placeholder?: string): void { + if (isUsable(reservedExternalWindow)) { + return; + } + + reservedExternalWindow = targetWindow.open() ?? undefined; + if (!isUsable(reservedExternalWindow)) { + reservedExternalWindow = undefined; + return; + } + + if (placeholder) { + showMessageInWindow(reservedExternalWindow, placeholder); + } +} + +/** Uses `textContent` so the message cannot inject markup. */ +function showMessageInWindow(target: Window, message: string): void { + try { + const doc = target.document; + doc.title = message; // otherwise announced as `about:blank` + doc.documentElement.style.cssText = 'color-scheme:light dark'; + doc.body.style.cssText = 'margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:Canvas;color:CanvasText;font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif'; + doc.body.textContent = message; + } catch { + // the window may already have navigated + } +} + +function takeReservedWindowForExternalOpen(): Window | undefined { + const reserved = reservedExternalWindow; + reservedExternalWindow = undefined; + return isUsable(reserved) ? reserved : undefined; +} + +/** + * Closes an unused reservation. + * + * @param fallbackMessage shown if the window refuses to close — an in-app browser + * view on iOS may ignore `close()`, and a blank window covering the app is worse + * than the problem this solves. + */ +export function releaseReservedWindowForExternalOpen(fallbackMessage?: string): void { + const reserved = takeReservedWindowForExternalOpen(); + if (!reserved) { + return; + } + + try { + reserved.close(); + } catch { + // the window may already have navigated + } + + if (!reserved.closed && fallbackMessage) { + showMessageInWindow(reserved, fallbackMessage); + } +} + /** * Attempts to open a window and returns whether it succeeded. This technique is * not appropriate in certain contexts, like for example when the JS context is @@ -1650,10 +1729,11 @@ export function windowOpenPopup(url: string): void { * @param url the url to open * @param noOpener whether or not to set the {@link window.opener} to null. You should leave the default * (true) unless you trust the url that is being opened. + * @param targetWindow the window to open from when no window was reserved. * @returns boolean indicating if the {@link window.open} call succeeded */ -export function windowOpenWithSuccess(url: string, noOpener = true): boolean { - const newTab = mainWindow.open(); +export function windowOpenWithSuccess(url: string, noOpener = true, targetWindow: Window = mainWindow): boolean { + const newTab = takeReservedWindowForExternalOpen() ?? targetWindow.open(); if (newTab) { if (noOpener) { // see `windowOpenNoOpener` for details on why this is important diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index e9dae9d5e96f3..44e683fe5dce4 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, EventHelper, EventType, getWindow, h, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { addDisposableListener, EventHelper, EventType, getWindow, h, scheduleAtNextAnimationFrame, trackFocus } from '../../../../base/browser/dom.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; @@ -36,6 +36,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; public readonly maxScroll; @@ -70,9 +71,12 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate(this, undefined); this._collapsed = derived(this, reader => this._viewModel.read(reader)?.collapsed.read(reader)); this._editorContentHeight = observableValue(this, 500); + this._itemHorizontalInsets = this._workbenchUIElementFactory.diffEditorItemHorizontalInsets ?? { left: 9, right: 9 }; this.size = derived(this, reader => { - const collapsed = this._collapsed.read(reader); - return (collapsed ? 0 : this._editorContentHeight.read(reader)) + this._outerEditorHeight; + if (this._collapsed.read(reader)) { + return this._headerHeight; + } + return this._editorContentHeight.read(reader) + this._outerEditorHeight; }); this._modifiedContentWidth = observableValue(this, 0); this._modifiedWidth = observableValue(this, 0); @@ -129,15 +133,17 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this._viewModel.get()?.setActive(undefined); this._register(autorun(reader => { btn.element.className = ''; btn.icon = this._collapsed.read(reader) ? Codicon.chevronRight : Codicon.chevronDown; })); this._register(btn.onDidClick(() => { + activateItem(); this._viewModel.get()?.collapsed.set(!this._collapsed.get(), undefined); })); @@ -159,8 +165,17 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this._elements.root.classList.add('header-hovered'))); + this._register(addDisposableListener(this._elements.header, EventType.MOUSE_LEAVE, () => this._elements.root.classList.remove('header-hovered'))); + const headerFocus = this._register(trackFocus(this._elements.header)); + this._register(headerFocus.onDidFocus(() => { + this._elements.root.classList.add('header-focused'); + activateItem(); + })); + this._register(headerFocus.onDidBlur(() => this._elements.root.classList.remove('header-focused'))); this._register(addDisposableListener(this._elements.header, EventType.CLICK, (e) => { + activateItem(); // Don't toggle if clicking on actions or the collapse button itself (already handled) const target = e.target; if (!(target instanceof Element)) { @@ -174,6 +189,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { if (e.key === 'Enter' || e.key === ' ') { + activateItem(); const target = e.target; if (target instanceof Element && (target.closest('.actions') || target.closest('.collapse-button'))) { return; @@ -218,10 +234,12 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { const isActive = this._viewModel.read(reader)?.isActive.read(reader); this._elements.root.classList.toggle('active', isActive); + const isFirst = this._viewModel.read(reader)?.isFirst.read(reader); + this._elements.root.classList.toggle('first-diff-entry', isFirst); })); this._container.appendChild(this._elements.root); - this._outerEditorHeight = this._headerHeight; + this._outerEditorHeight = this._headerHeight + (this._workbenchUIElementFactory.diffEditorItemContentBottomPadding ?? 0); this._contextKeyService = this._register(_parentContextKeyService.createScoped(this._elements.actions)); const ctxAllUnchangedRegionsShown = EditorContextKeys.multiDiffEditorItemAllUnchangedRegionsShown.bindTo(this._contextKeyService); @@ -421,7 +439,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { this.editor.layout({ - width: width - 2 * 8 - 2 * 1, + width: width - this._itemHorizontalInsets.left - this._itemHorizontalInsets.right, height: verticalRange.length - this._outerEditorHeight, }); }); @@ -447,6 +465,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this.items.read(reader).find(i => i.isFocused.read(reader))); public readonly activeDiffItem = derivedObservableWithWritableCache(this, - (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined + (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1 ? lastValue : undefined) ); public async waitForDiffOr1s(): Promise { @@ -137,6 +137,11 @@ export class DocumentDiffItemViewModel extends Disposable { public get modifiedUri(): URI | undefined { return this.documentDiffItem.modified?.uri; } public readonly isActive: IObservable = derived(this, reader => this._editorViewModel.activeDiffItem.read(reader) === this); + public readonly isFirst: IObservable = derived(this, reader => this._editorViewModel.items.read(reader)[0] === this); + + public setActive(tx: ITransaction | undefined): void { + this._editorViewModel.activeDiffItem.setCache(this, tx); + } private readonly _isFocusedSource = observableValue>(this, constObservable(false)); public readonly isFocused = derived(this, reader => this._isFocusedSource.read(reader).read(reader)); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 599cd1c936e09..befafc3fcf06d 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -27,6 +27,7 @@ export class MultiDiffEditorWidget extends Disposable { private readonly _dimension = observableValue(this, undefined); private readonly _viewModel = observableValue(this, undefined); private readonly _diffLayoutOptions = observableValue(this, undefined); + private readonly _paddingBottomPx = observableValue(this, 0); private readonly _widgetImpl = derived(this, (reader) => { readHotReloadableExport(DiffEditorItemTemplate, reader); @@ -38,6 +39,7 @@ export class MultiDiffEditorWidget extends Disposable { this._workbenchUIElementFactory, this._diffLayoutOptions, this._diffEditorOptions, + this._paddingBottomPx, )); }); @@ -112,6 +114,11 @@ export class MultiDiffEditorWidget extends Disposable { this.setRenderSideBySide(!(this._diffLayoutOptions.get()?.renderSideBySide ?? true)); } + /** Reserves empty space below the last diff entry. */ + public setPaddingBottom(px: number): void { + this._paddingBottomPx.set(px, undefined); + } + private readonly _activeControl = derived(this, (reader) => this._widgetImpl.read(reader).activeControl.read(reader)); public getActiveControl(): DiffEditorWidget | undefined { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 8c17e619bfd1e..cdf7d096bc0b6 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -6,7 +6,7 @@ import { Dimension, h } from '../../../../base/browser/dom.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, IReader, ITransaction, autorun, autorunWithStore, derived, mapObservableArrayCached, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, IReader, ITransaction, autorun, autorunWithStore, constObservable, derived, mapObservableArrayCached, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ContextKeyValue, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -71,6 +71,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, private readonly _diffLayoutOptions: IObservable, private readonly _diffEditorOptions: IDiffEditorOptions | undefined, + private readonly _paddingBottomPx: IObservable, @IContextKeyService private readonly _parentContextKeyService: IContextKeyService, @IInstantiationService private readonly _parentInstantiationService: IInstantiationService, @ILogService logService: ILogService, @@ -81,6 +82,12 @@ export class MultiDiffEditorWidgetImpl extends Disposable { return { ...this._diffEditorOptions, ...this._diffLayoutOptions.read(reader) }; }); this._spaceBetweenPx = observableValue(this, 0); + const paddingBottomItem = this._paddingBottomPx.map(this, size => ({ + size: constObservable(size), + maxScroll: constObservable({ maxScroll: 0 }), + render() { }, + hide() { }, + })); let viewItemsInfo!: IObservable<{ items: readonly VirtualizedViewItem[]; getItem: (viewModel: DocumentDiffItemViewModel) => VirtualizedViewItem }>; let viewItems!: IObservable; @@ -93,7 +100,9 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const manager = this._register(new VirtualizedItemManager(sourceItems, context, { getId: item => item, getTemplateId: () => 'diffEditor', - getUnboundSize: item => derived(item, reader => item.collapsed.read(reader) ? 40 : item.lastTemplateData.read(reader).expandedContentHeight), + getUnboundSize: item => derived(item, reader => item.collapsed.read(reader) + ? this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40 + : item.lastTemplateData.read(reader).expandedContentHeight), createTemplate: () => this._instantiationService.createInstance( DiffEditorItemTemplate, context.contentDomNode, @@ -143,7 +152,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { return { items, getItem: d => map.get(d)! }; }); viewItems = viewItemsInfo.map(this, items => items.items); - return viewItems; + return derived(this, reader => [...viewItems.read(reader), paddingBottomItem.read(reader)]); }, )); this._viewItemsInfo = viewItemsInfo; diff --git a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts index a69dde587e7ca..f853df6d75ad9 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts @@ -28,6 +28,15 @@ export const enum MultiDiffEditorItemLabelKind { export interface IWorkbenchUIElementFactory { createResourceLabel?(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel; + /** Horizontal insets reserved around each embedded diff editor. */ + readonly diffEditorItemHorizontalInsets?: Readonly<{ left: number; right: number }>; + + /** Height of each entry's file header, in px. Defaults to 40. */ + readonly diffEditorItemHeaderHeight?: number; + + /** Padding reserved below each embedded diff editor, in px. Defaults to 0. */ + readonly diffEditorItemContentBottomPadding?: number; + /** * When true, the entire header area is clickable to toggle collapse/expand * and receives keyboard activation (Enter/Space) and ARIA button semantics. diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index 2479bbf4be99c..7150e1bc0e3ce 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -38,6 +38,50 @@ suite('MultiDiffEditorWidget', () => { sinon.restore(); }); + test('models bottom padding as trailing scroll content', () => { + const services = new ServiceCollection(); + services.set(IAccessibilitySignalService, new class extends mock() { }()); + services.set(IActionViewItemService, new NullActionViewItemService()); + services.set(IEditorProgressService, new class extends mock() { }()); + services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); + services.set(IStorageService, disposables.add(new InMemoryStorageService())); + services.set(IMenuService, new class extends mock() { + override createMenu(): IMenu { + return new class extends mock() { + override readonly onDidChange = Event.None; + override getActions() { return []; } + override dispose(): void { } + }(); + } + }()); + const instantiationService = createCodeEditorServices(disposables, services); + const container = document.createElement('div'); + const widget = instantiationService.createInstance( + MultiDiffEditorWidget, + container, + {} satisfies IWorkbenchUIElementFactory, + undefined, + ); + widget.layout(new Dimension(800, 200)); + const initialState = widget.getLayoutDebugState().get(); + widget.setPaddingBottom(24); + + try { + const state = widget.getLayoutDebugState().get(); + assert.deepStrictEqual({ + logicalScrollHeightDelta: state.layout.logicalScrollHeight - initialState.layout.logicalScrollHeight, + scrollHeightDelta: state.scrollDimensions.scrollHeight - initialState.scrollDimensions.scrollHeight, + diffItems: state.items.length, + }, { + logicalScrollHeightDelta: 24, + scrollHeightDelta: 24, + diffItems: 0, + }); + } finally { + widget.dispose(); + } + }); + test('applies document and responsive layout options before attaching the diff model', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index d79e11e400d22..ade98d06b9c82 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -1437,22 +1437,22 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect modifiedTime: Date.parse(s.modifiedAt), ...(s.project ? { project: { - uri: this._toLocalProjectUri(URI.parse(s.project.uri)), + uri: this._toClientUri(URI.parse(s.project.uri)), displayName: s.project.displayName, } } : {}), summary: s.title, status: s.status, activity: s.activity, - workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? toAgentHostUri(URI.parse(s.workingDirectories?.[0]), this._connectionAuthority) : undefined, - workingDirectories: s.workingDirectories?.map(d => toAgentHostUri(URI.parse(d), this._connectionAuthority)), + workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? this._toClientUri(URI.parse(s.workingDirectories[0])) : undefined, + workingDirectories: s.workingDirectories?.map(d => this._toClientUri(URI.parse(d))), changes: s.changes, // Carry durable host provenance for sessions first materialized from a listing. ...(s._meta !== undefined ? { _meta: s._meta } : {}), })); } - private _toLocalProjectUri(uri: URI): URI { + private _toClientUri(uri: URI): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, this._connectionAuthority) : uri; } diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 67eac47b7d5d6..78bb549841ebe 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -13,6 +13,8 @@ import type { ChangesetOperation, ISessionGitHubState, ISessionGitState, URI } f export const IAgentHostChangesetOperationService = createDecorator('agentHostChangesetOperationService'); export const AGENT_HOST_MERGE_CHANGESET_OPERATION_ID = 'merge'; +export const AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID = 'commit'; +export const AGENT_HOST_SYNC_CHANGESET_OPERATION_ID = 'sync'; /** * Changeset operations advertised for a branch that already has a pull diff --git a/src/vs/platform/agentHost/common/agentModelNotices.ts b/src/vs/platform/agentHost/common/agentModelNotices.ts new file mode 100644 index 0000000000000..2fb7721b65737 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentModelNotices.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IAgentModelInfo } from './agent.js'; +import type { SessionModelInfo } from './state/protocol/state.js'; + +const DATA_RETENTION_WARNING_CODE = 'data_retention'; +const PENDING_DEPRECATION_WARNING_CODE = 'model_pending_deprecation'; + +type ModelMessage = { readonly code: string; readonly message: string }; +type ModelNoticeSource = { + readonly warningText?: { readonly dataRetention?: string }; + readonly infoMessages?: readonly ModelMessage[]; + readonly warningMessages?: readonly ModelMessage[]; +}; + +/** Converts SDK model messages to model-picker metadata. */ +export function createAgentModelNoticesMeta(source: ModelNoticeSource): Record | undefined { + const warningText: Record = {}; + const infoText: Record = {}; + if (source.warningText?.dataRetention) { + warningText[DATA_RETENTION_WARNING_CODE] = source.warningText.dataRetention; + } + for (const { code, message } of source.infoMessages ?? []) { + if (message) { + const target = code === PENDING_DEPRECATION_WARNING_CODE ? warningText : infoText; + target[code || 'info'] = message; + } + } + for (const { code, message } of source.warningMessages ?? []) { + if (message) { + warningText[code || 'warning'] = message; + } + } + const rowWarning = source.warningMessages?.find(({ message }) => !!message)?.message + ?? warningText[PENDING_DEPRECATION_WARNING_CODE]; + const result = { + ...(Object.keys(warningText).length > 0 ? { warningText } : {}), + ...(Object.keys(infoText).length > 0 ? { infoText } : {}), + ...(rowWarning ? { rowWarning } : {}), + }; + return Object.keys(result).length > 0 ? result : undefined; +} + +/** Reads model-picker messages from Agent Host metadata. */ +export function readAgentModelNoticesMeta(model: IAgentModelInfo | SessionModelInfo) { + const meta = model._meta; + return { + warningText: asStringDictionary(meta?.warningText), + infoText: asStringDictionary(meta?.infoText), + rowWarning: typeof meta?.rowWarning === 'string' && meta.rowWarning.length > 0 ? meta.rowWarning : undefined, + }; +} + +function asStringDictionary(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const entries = Object.entries(value).filter((entry): entry is [string, string] => + entry[0].length > 0 && typeof entry[1] === 'string' && entry[1].length > 0); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 2740e9ce21674..1b54a6cbfb69a 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1609,6 +1609,10 @@ export interface ISessionGitHubState { readonly initialPullRequestUrls?: readonly string[]; /** Pull requests explicitly associated through user intent, most recent first. */ readonly associatedPullRequestUrls?: readonly string[]; + /** Last host-observed state of {@link pullRequestStateUrl}. */ + readonly pullRequestState?: 'open' | 'closed' | 'merged'; + /** Pull request URL to which {@link pullRequestState} applies. */ + readonly pullRequestStateUrl?: string; /** * The name of the branch the most recent {@link pullRequestUrls} entry was found (or created) for. * A pull request always relates to a branch: when the working copy switches @@ -1663,10 +1667,15 @@ export function withMostRecentSessionPullRequest(gitHubState: ISessionGitHubStat pullRequestUrl, ...(gitHubState?.pullRequestUrls ?? []) ]); + const normalizedPullRequestUrl = pullRequestUrls[0]?.toLowerCase(); + const stateApplies = gitHubState?.pullRequestStateUrl?.toLowerCase() === normalizedPullRequestUrl; return { pullRequestUrls, pullRequestBranchName: branchName, + ...(stateApplies && gitHubState?.pullRequestState && gitHubState.pullRequestStateUrl + ? { pullRequestState: gitHubState.pullRequestState, pullRequestStateUrl: gitHubState.pullRequestStateUrl } + : {}), }; } @@ -1799,6 +1808,8 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS pullRequestUrls?: readonly string[]; initialPullRequestUrls?: readonly string[]; associatedPullRequestUrls?: readonly string[]; + pullRequestState?: 'open' | 'closed' | 'merged'; + pullRequestStateUrl?: string; pullRequestBranchName?: string; } = {}; @@ -1821,6 +1832,10 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS result.associatedPullRequestUrls = associatedPullRequestUrls; } } + if (raw['pullRequestState'] === 'open' || raw['pullRequestState'] === 'closed' || raw['pullRequestState'] === 'merged') { + result.pullRequestState = raw['pullRequestState']; + } + if (typeof raw['pullRequestStateUrl'] === 'string') { result.pullRequestStateUrl = raw['pullRequestStateUrl']; } if (typeof raw['pullRequestBranchName'] === 'string') { result.pullRequestBranchName = raw['pullRequestBranchName']; } return result; } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts b/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts index 55436558c1bfb..86b01f40bdf59 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts @@ -7,7 +7,7 @@ import { URI } from '../../../base/common/uri.js'; import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentSession } from '../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from './agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from './agentHostTelemetryReporter.js'; /** The static changeset slot a compute was for. */ export type StaticChangesetTelemetryKind = 'branch' | 'session' | 'uncommitted'; @@ -114,7 +114,7 @@ export type ChangesetComputedKind = StaticChangesetTelemetryKind | 'turn'; /** The union of static and per-turn compute outcomes. */ export type ChangesetComputedOutcome = StaticChangesetOutcome | TurnChangesetOutcome; -type ChangesetComputedEvent = IAgentHostInitiatorTelemetry & { +type ChangesetComputedEvent = IAgentHostEventTelemetry & { provider: string; agentSessionId: string; turnId?: string; @@ -131,7 +131,7 @@ type ChangesetComputedEvent = IAgentHostInitiatorTelemetry & { trackedEditFallbackFolderCount?: number; }; -type ChangesetComputedClassification = IAgentHostInitiatorClassification & { +type ChangesetComputedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; turnId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'For a turn changeset, the turn whose changeset was computed; for a static changeset, the turn that drove the recompute when one did (absent for truncation/refresh recomputes).' }; @@ -154,7 +154,7 @@ type ChangesetComputedClassification = IAgentHostInitiatorClassification & { * Shared emitter for `agentHost.changesetComputed`. Correlation (`provider`, * `agentSessionId`) is derived from `session`; `turnId` is included when set. */ -function reportChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, fields: Omit, clientContext?: IAgentHostClientTelemetryContext): void { +function reportChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, fields: Omit, clientContext?: IAgentHostClientTelemetryContext): void { telemetryService.publicLog2('agentHost.changesetComputed', { ...toInitiatorTelemetry(clientContext), provider: URI.parse(session).scheme, diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index 251b3d8b50670..418bd94a23324 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -10,7 +10,7 @@ import { localize } from '../../../nls.js'; import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; -import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { readSessionGitState, type ISessionFileDiff, type SessionState } from '../common/state/sessionState.js'; @@ -22,7 +22,7 @@ const MAX_CHANGE_SUMMARY_PROMPT_CHARS = 20_000; export class AgentHostCommitOperationHandler implements IChangesetOperationHandler { - public static readonly OPERATION_COMMIT = 'commit'; + public static readonly OPERATION_COMMIT = AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID; constructor( private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index 6d8480074a710..76ab7b2613727 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -35,7 +35,8 @@ export class AgentHostCommitOperationContribution extends Disposable implements } getOperations({ sessionKey, changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { - if ((gitState?.uncommittedChanges ?? 0) <= 0) { + const isNewSession = this._stateManager.isUnusedDraft(sessionKey) === true; + if (!isNewSession && (gitState?.uncommittedChanges ?? 0) <= 0) { return []; } diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index a4a96d461cbd5..72eca2b681381 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -274,9 +274,13 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; const currentState = readSessionGitHubState(currentMeta); - const nextState = { ...(currentState ?? {}), ...state } satisfies ISessionGitHubState; + let nextState = { ...(currentState ?? {}), ...state } satisfies ISessionGitHubState; const currentPullRequest = getSessionRelatedPullRequestUrls(currentState)[0]; const nextPullRequest = getSessionRelatedPullRequestUrls(nextState)[0]; + if (currentPullRequest !== nextPullRequest && state.pullRequestStateUrl === undefined) { + const { pullRequestState: _ignoredState, pullRequestStateUrl: _ignoredStateUrl, ...stateWithoutPullRequestStatus } = nextState; + nextState = stateWithoutPullRequestStatus; + } const currentSourceControlState = readSessionSourceControlState(currentMeta); const nextSourceControlState = nextPullRequest && nextPullRequest !== currentPullRequest ? { ...currentSourceControlState, latestOutcome: SessionSourceControlOutcome.PullRequest } satisfies ISessionSourceControlState diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts index 48e26fb4ead7c..c1602c25d3b51 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts @@ -147,6 +147,7 @@ export class AgentHostPullRequestLifecycleOperationHandler implements IChangeset const method = this._requireMergeMethod(mergeability?.allowedMergeMethods ?? []); const result = await this._gitHubService.mutations.merge(preparation, { method, authorization }, signal); + this._statusService.markPullRequestMerged(sessionUri, status.url); this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Pull request merged: session=${sessionUri}, pr=${status.url}, method=${method}, outcome=${result.outcome}`); return localize('agentHost.changeset.pr.merged', "Pull request was merged."); } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 3dbfca1d1c742..7685d9fea8c38 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -20,6 +20,8 @@ import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/co import { buildConversationContext } from '../common/agentHostConversationContext.js'; import { IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { readAgentMergeSessionState } from '../common/agentMerge.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; /** * Soft upper bound, in characters, for the conversation context fed to the @@ -43,9 +45,9 @@ export interface PullRequestCreatedEvent { } /** - * Server-side handler for the `create-pr` and `create-draft-pr` changeset - * operations advertised on git-backed sessions whose working directory has - * a GitHub remote. Operation availability is recomputed by + * Server-side handler for pull request creation changeset operations advertised + * on git-backed sessions whose working directory has a GitHub remote. + * Operation availability is recomputed by * `AgentHostChangesetOperationService.updateOperations`. * * The flow mirrors the Copilot CLI extension's `createPullRequest` helper @@ -69,10 +71,12 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation public static readonly OPERATION_CREATE_PR_AUTO_MERGE = 'create-pr-auto-merge'; public static readonly OPERATION_CREATE_PR_AUTO_SQUASH = 'create-pr-auto-squash'; public static readonly OPERATION_CREATE_PR_AUTO_REBASE = 'create-pr-auto-rebase'; + public static readonly OPERATION_CREATE_PR_AGENT_MERGE = 'create-pr-agent-merge'; constructor( private readonly _draft: boolean, private readonly _autoMergeMethod: AutoMergeMethod | undefined, + private readonly _enableAgentMerge: boolean, private readonly _getSessionState: (sessionKey: string) => ISessionWithDefaultChat | undefined, private readonly _resolveBaseBranchName: (sessionKey: string) => Promise, private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void, @@ -82,6 +86,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @IAgentBranchNameGenerator private readonly _branchNameGenerator: IAgentBranchNameGenerator, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @ILogService private readonly _logService: ILogService, ) { } @@ -270,9 +275,8 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation /** * Notifies listeners that the pull request now exists, optionally enables - * auto-merge with the configured {@link AutoMergeMethod} (best-effort: a - * failure to enable auto-merge does not fail the operation), and builds the - * result message describing what happened. + * auto-merge or Agent Merge, and builds the result message describing what + * happened. A failure to enable auto-merge does not fail the operation. */ private async _finalize( pr: CreatedPullRequest, @@ -287,7 +291,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation ): Promise { if (!this._autoMergeMethod) { // No auto-merge configured - this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url, branchName }); + this._completePullRequestOperation(sessionUri, pr.url, branchName); return this._createResult(pr, this._buildMessage(pr, isExisting, 'none', undefined)); } @@ -310,11 +314,32 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation this._logService.warn(`[AgentHostPullRequestOperationHandler] Cannot enable auto-merge for ${owner}/${repo}#${pr.number}: missing pull request node id`); } - this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url, branchName }); + this._completePullRequestOperation(sessionUri, pr.url, branchName); return this._createResult(pr, this._buildMessage(pr, isExisting, autoMergeOutcome, autoMergeError)); } + private _completePullRequestOperation(sessionUri: string, pullRequestUrl: string, branchName: string): void { + this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl, branchName }); + if (!this._enableAgentMerge) { + return; + } + const current = readAgentMergeSessionState(this._configurationService.getSessionConfigValues(sessionUri)); + this._configurationService.updateSessionConfig(sessionUri, { + [SessionConfigKey.AgentMerge]: { + enabled: true, + ...(current?.overrides ? { overrides: current.overrides } : {}), + }, + [SessionConfigKey.AgentMergeController]: {}, + }); + } + private _buildMessage(pr: CreatedPullRequest, isExisting: boolean, autoMergeOutcome: 'none' | 'enabled' | 'failed', autoMergeError: string | undefined): string { + if (this._enableAgentMerge) { + return isExisting + ? localize('agentHost.changeset.pr.existing.agentMerge', "Pull request [#{0}]({1}) already exists; enabled Agent Merge.", pr.number, pr.url) + : localize('agentHost.changeset.pr.created.agentMerge', "Created pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url); + } + let mergeMethodLabel: string | undefined; switch (this._autoMergeMethod) { case 'SQUASH': diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index 303f73c633584..8e4ee53a0880f 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -15,6 +15,8 @@ import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } fr import { AgentHostPullRequestLifecycleOperationHandler } from './agentHostPullRequestLifecycleOperationHandler.js'; import { IAgentHostPullRequestStatusService } from './agentHostPullRequestStatusService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { AgentMergeConfigKey, agentMergeRootConfigSchema } from '../common/agentMerge.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { @@ -28,6 +30,7 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, @IAgentHostPullRequestStatusService private readonly _pullRequestStatusService: IAgentHostPullRequestStatusService, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @ILogService private readonly _logService: ILogService, ) { super(); @@ -40,16 +43,18 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); const resolveBaseBranchName = (sessionKey: string) => this._gitStateService.resolveSessionBaseBranchName(sessionKey); const onCreated = (event: PullRequestCreatedEvent) => this._onPullRequestCreated(event); - const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, getSessionState, resolveBaseBranchName, onCreated); - const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, undefined, getSessionState, resolveBaseBranchName, onCreated); - const createAutoMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'MERGE', getSessionState, resolveBaseBranchName, onCreated); - const createAutoSquashPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'SQUASH', getSessionState, resolveBaseBranchName, onCreated); - const createAutoRebasePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'REBASE', getSessionState, resolveBaseBranchName, onCreated); + const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, false, getSessionState, resolveBaseBranchName, onCreated); + const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, undefined, false, getSessionState, resolveBaseBranchName, onCreated); + const createAutoMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'MERGE', false, getSessionState, resolveBaseBranchName, onCreated); + const createAutoSquashPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'SQUASH', false, getSessionState, resolveBaseBranchName, onCreated); + const createAutoRebasePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'REBASE', false, getSessionState, resolveBaseBranchName, onCreated); + const createAgentMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, true, getSessionState, resolveBaseBranchName, onCreated); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_MERGE, createAutoMergePrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_SQUASH, createAutoSquashPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_REBASE, createAutoRebasePrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, createAgentMergePrHandler)); for (const [operationId, action] of [ [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, 'mark-ready'], @@ -61,6 +66,17 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem } store.add(this._pullRequestStatusService.onDidChangePullRequestStatus(sessionKey => registry.onDidChangeOperations(sessionKey))); + let agentMergeEnabled = this._isAgentMergeEnabled(); + store.add(this._configurationService.onDidRootConfigChange(() => { + const nextAgentMergeEnabled = this._isAgentMergeEnabled(); + if (agentMergeEnabled === nextAgentMergeEnabled) { + return; + } + agentMergeEnabled = nextAgentMergeEnabled; + for (const sessionKey of this._stateManager.getSessionUris()) { + registry.onDidChangeOperations(sessionKey); + } + })); store.add({ dispose: () => { this._registry = undefined; } }); return store; } @@ -128,6 +144,14 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle, }, + ...(this._isAgentMergeEnabled() ? [{ + id: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, + label: localize('agentHost.changeset.createPRAgentMerge', "Create PR & Enable Agent Merge"), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }] : []), { id: 'create-draft-pr', label: localize('agentHost.changeset.createDraftPR', "Create Draft PR"), @@ -138,6 +162,10 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem }] satisfies ChangesetOperation[]; } + private _isAgentMergeEnabled(): boolean { + return this._configurationService.getRootValue(agentMergeRootConfigSchema, AgentMergeConfigKey.Enabled) === true; + } + /** * Operations for a branch that already has a pull request. * diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts index 8079f47406210..e7a11c311b4c1 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts @@ -74,6 +74,9 @@ export interface IAgentHostPullRequestStatusService extends IDisposable { */ getPullRequestStatus(sessionKey: string): IAgentHostPullRequestStatus | undefined; + /** Records a successful direct merge before the next GitHub refresh completes. */ + markPullRequestMerged(sessionKey: string, pullRequestUrl: string): void; + /** * Re-reads the pull request from GitHub, bypassing cached fragments. Used * after a mutation so the advertised operations reflect the new state @@ -85,6 +88,7 @@ export interface IAgentHostPullRequestStatusService extends IDisposable { interface IWatch extends IDisposable { readonly ref: PullRequestRef; readonly subscription: PullRequestSubscription; + awaitingAuthoritativeRefresh: boolean; status?: IAgentHostPullRequestStatus; } @@ -154,6 +158,32 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg return this._watches.get(sessionKey)?.status; } + markPullRequestMerged(sessionKey: string, pullRequestUrl: string): void { + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + const currentPullRequestUrl = getSessionRelatedPullRequestUrls(gitHubState)[0] ?? gitHubState?.pullRequestUrls?.[0]; + const mergedPullRequest = parsePullRequestUrl(pullRequestUrl); + const currentPullRequest = currentPullRequestUrl ? parsePullRequestUrl(currentPullRequestUrl) : undefined; + if (!mergedPullRequest || !currentPullRequest || !sameParsedRef(mergedPullRequest, currentPullRequest)) { + return; + } + + const watch = this._watches.get(sessionKey); + const status = watch?.status; + if (!watch || !status || !sameRefAndHost(watch.ref, mergedPullRequest)) { + this._publishPullRequestState(sessionKey, pullRequestUrl, 'merged'); + this._onDidChangePullRequestStatus.fire(sessionKey); + return; + } + this._setStatus(sessionKey, watch, { + ...status, + state: 'merged', + draft: false, + mergeReady: false, + viewerCanEnableAutoMerge: false, + autoMergeEnabled: false, + }); + } + async refresh(sessionKey: string): Promise { const watch = this._watches.get(sessionKey); if (!watch) { @@ -228,7 +258,7 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg } const existing = this._watches.get(sessionKey); - if (existing && sameRef(existing.ref, parsed)) { + if (existing && sameRefAndHost(existing.ref, parsed)) { return; } @@ -266,16 +296,45 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg const watch: IWatch = { ref, subscription, + awaitingAuthoritativeRefresh: this._hasPersistedMergedState(sessionKey, ref), dispose: () => store.dispose(), }; this._watches.set(sessionKey, watch); store.add(autorun(reader => { const snapshot = subscription.resource.snapshot.read(reader); + if (watch.awaitingAuthoritativeRefresh) { + return; + } this._updateStatus(sessionKey, watch, snapshot); })); + if (watch.awaitingAuthoritativeRefresh) { + void this._refreshRecreatedMergedWatch(sessionKey, watch); + } this._logService.debug(`[AgentHostPullRequestStatusService] Watching pull request: session=${sessionKey}, pr=${describeRef(ref)}`); } + private _hasPersistedMergedState(sessionKey: string, ref: PullRequestRef): boolean { + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + const persistedPullRequest = gitHubState?.pullRequestStateUrl ? parsePullRequestUrl(gitHubState.pullRequestStateUrl) : undefined; + return gitHubState?.pullRequestState === 'merged' + && persistedPullRequest !== undefined + && sameRefAndHost(ref, persistedPullRequest); + } + + private async _refreshRecreatedMergedWatch(sessionKey: string, watch: IWatch): Promise { + try { + await watch.subscription.refresh(undefined, undefined, { authoritative: true }); + } catch (error) { + this._logService.warn(`[AgentHostPullRequestStatusService] Failed to refresh recreated merged pull request watch: session=${sessionKey}, pr=${describeRef(watch.ref)}, error=${error}`); + return; + } + if (this._watches.get(sessionKey) !== watch) { + return; + } + watch.awaitingAuthoritativeRefresh = false; + this._updateStatus(sessionKey, watch, watch.subscription.resource.snapshot.get()); + } + /** * The pull request this session should be watching, or the reason it is not * eligible. The reason is carried rather than collapsed into `undefined` so @@ -304,7 +363,18 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg } private _updateStatus(sessionKey: string, watch: IWatch, snapshot: PullRequestSnapshot): void { - const status = toPullRequestStatus(snapshot); + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + const persistedPullRequest = gitHubState?.pullRequestStateUrl ? parsePullRequestUrl(gitHubState.pullRequestStateUrl) : undefined; + const persistedMergedStateApplies = gitHubState?.pullRequestState === 'merged' + && persistedPullRequest !== undefined + && sameRefAndHost(watch.ref, persistedPullRequest); + if (snapshot.core.status !== 'ready' && (watch.status?.state === 'merged' || persistedMergedStateApplies)) { + return; + } + this._setStatus(sessionKey, watch, toPullRequestStatus(snapshot)); + } + + private _setStatus(sessionKey: string, watch: IWatch, status: IAgentHostPullRequestStatus | undefined): void { if (structuralEquals(watch.status, status)) { return; } @@ -316,9 +386,23 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg // The single most useful line when a button bar shows the "wrong" // action: it names every flag the operation provider branches on. this._logService.debug(`[AgentHostPullRequestStatusService] Status changed: session=${sessionKey}, pr=${describeRef(watch.ref)}, from=[${describeStatus(previous)}], to=[${describeStatus(status)}]`); + if (status) { + this._publishPullRequestState(sessionKey, status.url, status.state); + } this._onDidChangePullRequestStatus.fire(sessionKey); } + private _publishPullRequestState(sessionKey: string, pullRequestUrl: string, state: IAgentHostPullRequestStatus['state']): void { + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + if (gitHubState?.pullRequestState === state && gitHubState.pullRequestStateUrl === pullRequestUrl) { + return; + } + void this._gitStateService.setSessionGitHubState(sessionKey, { + pullRequestState: state, + pullRequestStateUrl: pullRequestUrl, + }).catch(error => this._logService.warn(`[AgentHostPullRequestStatusService] Failed to publish pull request state: session=${sessionKey}, pr=${pullRequestUrl}, state=${state}, error=${error}`)); + } + private _stopWatch(sessionKey: string, reason?: string): void { const watch = this._watches.get(sessionKey); if (!watch) { @@ -401,12 +485,22 @@ function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullReque }; } -function sameRef(left: PullRequestRef, right: { readonly owner: string; readonly repo: string; readonly number: number }): boolean { +function sameRef(left: { readonly owner: string; readonly repo: string; readonly number: number }, right: { readonly owner: string; readonly repo: string; readonly number: number }): boolean { return left.owner.toLowerCase() === right.owner.toLowerCase() && left.repo.toLowerCase() === right.repo.toLowerCase() && left.number === right.number; } +type ParsedPullRequestUrl = NonNullable>; + +function sameParsedRef(left: ParsedPullRequestUrl, right: ParsedPullRequestUrl): boolean { + return left.apiHost.toLowerCase() === right.apiHost.toLowerCase() && sameRef(left, right); +} + +function sameRefAndHost(left: PullRequestRef, right: ParsedPullRequestUrl): boolean { + return left.host.toLowerCase() === right.apiHost.toLowerCase() && sameRef(left, right); +} + function sameAccount(left: GitHubAccountHandle, right: GitHubAccountHandle): boolean { return left.host.toLowerCase() === right.host.toLowerCase() && left.accountId === right.accountId; } diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts index e81ccd53e2f8e..ab4150c8423a8 100644 --- a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -133,8 +133,8 @@ export interface IAgentHostRestrictedTelemetry { sendInternalMSFTTelemetryEventForContext(context: IAgentHostInternalTelemetryContext, eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; /** Sets the Copilot user tracking id (`copilot_trackingId`) carried on every subsequent event. */ setCopilotTrackingId(trackingId: string | undefined): void; - /** Adds a property carried on every subsequent event, mirroring `ITelemetryService.setCommonProperty`. */ - setCommonProperty(name: string, value: string | boolean): void; + /** Adds or removes a property carried on every subsequent event, mirroring `ITelemetryService.setCommonProperty`. */ + setCommonProperty(name: string, value: string | boolean | undefined): void; /** Overrides the POST endpoint with the user's CAPI `endpoints.telemetry`; falsy restores the default. */ setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void; /** Enables enhanced GH telemetry once the authenticated account opts in; off by default and on flip/logout. */ @@ -229,8 +229,12 @@ export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedT this._commonProps.copilot_trackingId = trackingId || undefined; } - setCommonProperty(name: string, value: string | boolean): void { - this._commonProps[name] = String(value); + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this._commonProps[name]; + } else { + this._commonProps[name] = String(value); + } } setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { diff --git a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts index 1406ab8c07398..bb1383b71bc6a 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts @@ -12,6 +12,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import type { AgentProvider } from '../common/agent.js'; import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; +import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; export const AgentHostSessionSubscribeTimeoutMs = 60_000; @@ -37,7 +38,7 @@ export interface IAgentHostSessionOpenTelemetry { export const IAgentHostSessionOpenTelemetry = createDecorator('agentHostSessionOpenTelemetry'); -type AgentHostSessionSubscribeEvent = { +type AgentHostSessionSubscribeEvent = IAgentHostCopilotSkuTelemetry & { provider: string; channel: string; outcome: string; @@ -53,7 +54,7 @@ type AgentHostSessionSubscribeEvent = { totalDurationMs: number; }; -type AgentHostSessionSubscribeClassification = { +type AgentHostSessionSubscribeClassification = IAgentHostCopilotSkuClassification & { owner: 'roblourens'; comment: 'Measures Agent Host subscription latency from the subscribe request through session restoration and provider-specific resume work to the returned snapshot.'; provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Agent provider identifier.' }; diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts index bba21e32eb973..f9692db6e2ef8 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts @@ -11,12 +11,12 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { readSessionGitState, type SessionState } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; -import { IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_SYNC_CHANGESET_OPERATION_ID, IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; export class AgentHostSyncOperationHandler implements IChangesetOperationHandler { - public static readonly OPERATION_SYNC = 'sync'; + public static readonly OPERATION_SYNC = AGENT_HOST_SYNC_CHANGESET_OPERATION_ID; constructor( private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 12b4e402539f1..f8d91f3aaea88 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -42,6 +42,14 @@ export function getMessageOriginTelemetryKind(message: Message): AgentHostMessag return message.origin.kind; } +export interface IAgentHostCopilotSkuTelemetry { + copilotSku?: string; +} + +export type IAgentHostCopilotSkuClassification = { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; +}; + export interface IAgentHostInitiatorTelemetry { initiatorClientType?: AgentHostClientType; initiatorConnectionKind?: AgentHostClientConnectionKind; @@ -60,7 +68,11 @@ export type IAgentHostInitiatorClassification = { initiatorDevDeviceId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'SqmMachineId'; comment: 'The development device identifier of the VS Code client that initiated the event.' }; }; -export interface IAgentHostExecutionModeChangedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostEventTelemetry extends IAgentHostInitiatorTelemetry, IAgentHostCopilotSkuTelemetry { } + +export type IAgentHostEventClassification = IAgentHostInitiatorClassification & IAgentHostCopilotSkuClassification; + +export interface IAgentHostExecutionModeChangedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -69,7 +81,7 @@ export interface IAgentHostExecutionModeChangedEvent extends IAgentHostInitiator turnCount: number; } -export type IAgentHostExecutionModeChangedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostExecutionModeChangedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the mode change belongs to a subagent session.' }; @@ -80,7 +92,7 @@ export type IAgentHostExecutionModeChangedClassification = IAgentHostInitiatorCl comment: 'Reports agent host execution mode changes.'; }; -export interface IAgentHostUserMessageSentEvent { +export interface IAgentHostUserMessageSentEvent extends IAgentHostCopilotSkuTelemetry { provider: string; hostLaunchKind: AgentHostLaunchKind; initiatorClientId: string | undefined; @@ -100,7 +112,7 @@ export interface IAgentHostUserMessageSentEvent { attachmentCount: number; } -export type IAgentHostUserMessageSentClassification = { +export type IAgentHostUserMessageSentClassification = IAgentHostCopilotSkuClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; hostLaunchKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' }; initiatorClientId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The opaque AHP client identifier that initiated the message.' }; @@ -124,7 +136,7 @@ export type IAgentHostUserMessageSentClassification = { export type AgentHostClientConnectionAction = 'connected' | 'disconnected'; -export interface IAgentHostClientConnectionEvent { +export interface IAgentHostClientConnectionEvent extends IAgentHostCopilotSkuTelemetry { action: AgentHostClientConnectionAction; hostLaunchKind: AgentHostLaunchKind; clientId: string; @@ -144,7 +156,7 @@ export interface IAgentHostClientConnectionEvent { subscriptionCount: number | undefined; } -export type IAgentHostClientConnectionClassification = { +export type IAgentHostClientConnectionClassification = IAgentHostCopilotSkuClassification & { action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether an initialized AHP client transport connected or disconnected.' }; hostLaunchKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' }; clientId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The opaque AHP client identifier.' }; @@ -192,7 +204,7 @@ interface IAgentHostTurnAttributedReport { clientContext?: IAgentHostClientTelemetryContext; } -export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -221,7 +233,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemet modelCallCount: number; } -export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostTurnCompletedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the agent host session.' }; @@ -252,7 +264,7 @@ export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassific comment: 'Tracks agent host turn completion, including performance, configuration context, completed model responses, and billed AI credit usage when reported by the provider.'; }; -export interface IAgentHostTurnFailedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -268,7 +280,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostInitiatorTelemetry callstack: string | undefined; } -export type IAgentHostTurnFailedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the failed agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -388,7 +400,7 @@ function normalizeTurnActivityKind(activityKind: string): AgentHostTurnActivityT return turnActivityKindsByActionType[activityKind as keyof typeof turnActivityKindsByActionType] ?? 'other'; } -export interface IAgentHostTurnHungEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostTurnHungEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -415,7 +427,7 @@ export interface IAgentHostTurnHungEvent extends IAgentHostInitiatorTelemetry { permissionLevel: string | undefined; } -export type IAgentHostTurnHungClassification = IAgentHostInitiatorClassification & { +export type IAgentHostTurnHungClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the hung agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -467,7 +479,7 @@ export interface IAgentHostTurnHungReport extends IAgentHostTurnAttributedReport permissionLevel: string | undefined; } -export interface IAgentHostHungTurnCompletedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostHungTurnCompletedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -480,7 +492,7 @@ export interface IAgentHostHungTurnCompletedEvent extends IAgentHostInitiatorTel timeAfterHangMs: number; } -export type IAgentHostHungTurnCompletedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostHungTurnCompletedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the recovered agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -506,6 +518,10 @@ export interface IAgentHostHungTurnCompletedReport extends IAgentHostTurnAttribu timeAfterHangMs: number; } +type IAgentHostLanguageModelToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentHostEventTelemetry; + +type IAgentHostLanguageModelToolInvokedClassification = LanguageModelToolInvokedClassification & IAgentHostEventClassification; + export interface IAgentHostToolInvokedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; @@ -522,7 +538,7 @@ export interface IAgentHostToolInvokedReport extends IAgentHostTurnAttributedRep errorMessage: string | undefined; } -export type IAgentHostToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentHostInitiatorTelemetry & { +export type IAgentHostToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentHostEventTelemetry & { provider: string; agentSessionId: string; chatSessionId: string; @@ -531,7 +547,7 @@ export type IAgentHostToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentH msg: string | undefined; }; -export type IAgentHostToolInvokedClassification = Omit & IAgentHostInitiatorClassification & { +export type IAgentHostToolInvokedClassification = Omit & IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that invoked the tool.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the Agent Host session.' }; @@ -542,7 +558,7 @@ export type IAgentHostToolInvokedClassification = Omit('languageModelToolInvoked', { + this._telemetryService.publicLog2('languageModelToolInvoked', { ...toInitiatorTelemetry(report.clientContext), result: report.result, chatSessionId: session, diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index d6b7dbc68bb07..0a5fa70770dc5 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -203,7 +203,7 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT this._delegate.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this._delegate.setCommonProperty(name, value); this._restricted?.setCommonProperty(name, value); } diff --git a/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts index 8993588eefe71..4c35964638ce1 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts @@ -5,6 +5,7 @@ import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; import type { IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; // #region Failure classification @@ -66,7 +67,7 @@ export function classifyAgentSdkDownloadFailure(error: string | undefined): Agen // #region Telemetry -interface IAgentSdkDownloadEvent { +interface IAgentSdkDownloadEvent extends IAgentHostCopilotSkuTelemetry { packageId: string; phase: string; failureReason: string; @@ -76,7 +77,7 @@ interface IAgentSdkDownloadEvent { totalBytes: number; } -type AgentSdkDownloadClassification = { +type AgentSdkDownloadClassification = IAgentHostCopilotSkuClassification & { packageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which agent SDK was being fetched, e.g. claude or codex.' }; phase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the download started, completed, or failed.' }; failureReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Coarse bucket for a failed download (cancelled, network, filesystem, extract, notConfigured, unsupportedTarget, unknown). Empty unless the phase is failed.' }; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e31e50b2c9098..2581cc0954ff0 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -90,6 +90,7 @@ import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../c import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; +import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; @@ -124,7 +125,7 @@ interface ISessionListComputation { trailing?: Promise; } -type AgentHostLegacyMigrationEvent = { +type AgentHostLegacyMigrationEvent = IAgentHostCopilotSkuTelemetry & { provider: string; outcome: 'migrated' | 'skipped' | 'failed'; success: boolean; @@ -137,7 +138,7 @@ type AgentHostLegacyMigrationEvent = { reason: string; }; -type AgentHostLegacyMigrationClassification = { +type AgentHostLegacyMigrationClassification = IAgentHostCopilotSkuClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent provider id whose legacy session was migrated (e.g. copilotcli).' }; outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Migration outcome: migrated (adoption + restore completed), skipped (eligible legacy session not adopted this pass, e.g. migrate flag not yet applied), or failed (adoption or restore threw).' }; success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migration completed with at least one restored turn.' }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 842ef94b3f7a6..e3953553cab7d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -36,6 +36,7 @@ import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointSer import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; +import { createAgentModelNoticesMeta } from '../../common/agentModelNotices.js'; import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, DEFAULT_COPILOT_RUBBER_DUCK_ENABLED, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; @@ -44,7 +45,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; -import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js'; +import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel, type AutoModeTier } from '../../common/autoModeTiers.js'; import { isAutoModel } from './modelIdentifiers.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -82,7 +83,7 @@ import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitP import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js'; import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js'; -import { CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { CopilotAgentStartupConfig } from './copilotAgentStartupConfig.js'; import { ShellManager } from './copilotShellTools.js'; import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; @@ -1549,6 +1550,7 @@ export class CopilotAgent extends Disposable implements IAgent { return; } this._logService.info(`[Copilot] Auth token ${token ? 'updated' : 'cleared'}`); + this._telemetryService.setCommonProperty('copilotSku', undefined); this._githubToken = token; this._updateRestrictedTelemetry(token); this._refreshProxy(); @@ -1597,7 +1599,6 @@ export class CopilotAgent extends Disposable implements IAgent { try { const copilotSku = await this._copilotApiService.resolveCopilotSku?.(githubToken); if (copilotSku && this._githubToken === githubToken) { - // __GDPR__COMMON__ "copilotSku" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The raw Copilot entitlement SKU of the authenticated GitHub account." } this._telemetryService.setCommonProperty('copilotSku', copilotSku); } } catch (err) { @@ -2296,11 +2297,10 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - /** - * Builds the open `_meta` model picker bag from the SDK's billing and picker metadata. - */ private _createModelPickerMeta(modelInfo: CopilotModelInfo, billing: ICAPIModelBilling | undefined): Record | undefined { - return createPricingMetaFromBilling(billing, modelInfo.modelPickerPriceCategory, modelInfo.modelPickerCategory); + const pricing = createPricingMetaFromBilling(billing, modelInfo.modelPickerPriceCategory, modelInfo.modelPickerCategory); + const notices = isAutoModel(modelInfo.id) ? undefined : createAgentModelNoticesMeta(modelInfo); + return pricing || notices ? { ...pricing, ...notices } : undefined; } private _createModelConfigSchema(m: CopilotModelInfo, billing: ICAPIModelBilling | undefined): ConfigSchema | undefined { @@ -3693,6 +3693,13 @@ export class CopilotAgent extends Disposable implements IAgent { try { const resolvedAgent = provisional.isEphemeral ? undefined : await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); agent = resolvedAgent?.agent; + // Resolve the profile once and freeze it on the plan. The gate can flip while the session + // is provisional, or during the async launch below. + const autoTier = resolveCopilotAutoTier(provisional.model, this._configurationService, this._logService, sdkSessionId); + const model = provisional.model + ? this._withEffectiveAutoTier(provisional.model, autoTier, sdkSessionId, 'Auto routing profiles are unavailable') + : undefined; + provisional.model = model; const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client, @@ -3710,6 +3717,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: provisional.model, longContextWindow: this._longContextWindowFor(provisional.model?.id), freeLongContext: this._isFreeLongContext(provisional.model?.id), + autoTier, workspaceless: provisional.workspaceless, }; const chatChannelUri = this._findBoundSessionChatUri(sdkSessionId) ?? URI.parse(buildDefaultChatUri(sessionUri)); @@ -4189,6 +4197,8 @@ export class CopilotAgent extends Disposable implements IAgent { let launchPlan: CopilotSessionLaunchPlan; let sdkSessionId: string; let inheritedTurnId: string | undefined; + // Frozen before the async launch so the profile sent matches the one persisted below. + const forkAutoTier = resolveCopilotAutoTier(model, this._configurationService, this._logService, chatSdkId); let sourceEntry: CopilotAgentSession | undefined; if (fork) { sourceEntry = await this._ensureResolvedChatSession(this._resolveChatContext(fork.source, { configurationResource: forkSourceScope!, resource: this._resolveChatStorageScope(fork.source) })); @@ -4209,7 +4219,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, + fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id), autoTier: forkAutoTier }, }; } else { sdkSessionId = chatSdkId; @@ -4227,6 +4237,7 @@ export class CopilotAgent extends Disposable implements IAgent { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id), + autoTier: forkAutoTier, }; } @@ -4634,7 +4645,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) }, + fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id), autoTier: resolveCopilotAutoTier(info.model, this._configurationService, this._logService, context.sdkSessionId ?? context.configurationId) }, }; agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: configurationResource, chatChannelUri: chat, resource: context.resource }); await agentSession.initializeSession(); @@ -4740,29 +4751,33 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Rewrites a model selection so its Auto routing profile is the one `session` launched with. - * The runtime fixes the profile for a session's lifetime, so a requested change cannot be recorded. + * Rewrites a model selection so its Auto routing profile matches the one the runtime is actually + * using. Recording anything else would leave the picker and resume metadata out of step. */ - private _pinLaunchAutoTier(model: ModelSelection, session: CopilotAgentSession, sessionId: string): ModelSelection { + private _withEffectiveAutoTier(model: ModelSelection, effective: AutoModeTier | undefined, sessionId: string, reason: string): ModelSelection { // Only the Auto model routes per turn, so any other selection carries no profile to correct. if (!isAutoModel(model.id)) { return model; } const requested = model.config?.[AutoTierConfigKey]; - const launched = session.launchAutoTier; - if (requested === launched) { + if (requested === effective) { return model; } - this._logService.info(`[Copilot:${sessionId}] Auto routing profile is fixed for this session; keeping '${launched ?? 'the service default'}' instead of '${requested}'`); + this._logService.info(`[Copilot:${sessionId}] ${reason}; recording '${effective ?? 'the service default'}' instead of '${requested}'`); const config = { ...model.config }; - if (launched === undefined) { + if (effective === undefined) { delete config[AutoTierConfigKey]; } else { - config[AutoTierConfigKey] = launched; + config[AutoTierConfigKey] = effective; } return Object.keys(config).length > 0 ? { ...model, config } : { id: model.id }; } + /** The selection to record for a session that launched with `session`'s fixed routing profile. */ + private _pinLaunchAutoTier(model: ModelSelection, session: CopilotAgentSession, sessionId: string): ModelSelection { + return this._withEffectiveAutoTier(model, session.launchAutoTier, sessionId, 'Auto routing profile is fixed for this session'); + } + private async _changeAgent(chat: URI, agent: AgentSelection | undefined, operationContext: URI | IAgentChatContext): Promise { try { await this._changeAgentOnce(chat, agent, operationContext); @@ -5184,6 +5199,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: storedMetadata.model, longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), freeLongContext: this._isFreeLongContext(storedMetadata.model?.id), + autoTier: resolveCopilotAutoTier(storedMetadata.model, this._configurationService, this._logService, sessionId), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 92df62f545f21..e61218f1cbbf9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -56,10 +56,10 @@ import { ActionType, isChatAction, type ChatAction, type SessionAction } from '. import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, createErrorResponsePart, isSubagentSession, parseRequiredSessionUriFromChatUri, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, resolveCopilotAutoTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +import { clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME } from './toolSearchDeferral.js'; import { ActiveClientToolSet } from '../activeClientState.js'; -import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; import { AgentHostRepoInfoTelemetry } from '../agentHostRepoInfoTelemetry.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; @@ -3090,11 +3090,10 @@ export class CopilotAgentSession extends Disposable { /** * The Auto routing profile this session launched with, which the runtime fixes for the session's - * lifetime. `undefined` when the session routes with the service default. + * lifetime. Read from the frozen plan so a later gate flip cannot change what it reports. */ get launchAutoTier(): AutoModeTier | undefined { - const model = this._launchPlan.kind === 'create' ? this._launchPlan.model : this._launchPlan.fallback.model; - return resolveCopilotAutoTier(model, this._configurationService, this._logService, this.sessionId); + return this._launchPlan.kind === 'create' ? this._launchPlan.autoTier : this._launchPlan.fallback.autoTier; } async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier']): Promise { @@ -5885,7 +5884,7 @@ export class CopilotAgentSession extends Disposable { } } - type AgentHostInstructionsCollectedEvent = IAgentHostInitiatorTelemetry & { + type AgentHostInstructionsCollectedEvent = IAgentHostEventTelemetry & { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -5895,7 +5894,7 @@ export class CopilotAgentSession extends Disposable { referencedInstructionsCount: number; claudeMdCount: number; }; - type AgentHostInstructionsCollectedClassification = IAgentHostInitiatorClassification & { + type AgentHostInstructionsCollectedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that emitted this event (e.g. copilotcli). Absent on local rows; use presence to distinguish AH from local.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier. Absent on local rows.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the emission was from a subagent session.' }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts index d62d42d19fcfa..a2c8e018f5780 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts @@ -11,7 +11,7 @@ import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostCopilotSkuClassification, type IAgentHostCopilotSkuTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; export type CopilotClientOperation = 'abort' | 'changeAgent' | 'changeModel' | 'getSessionMetadata' | 'listSessions' | 'modelRefresh' | 'resumeTurn' | 'sendMessage'; export type CopilotClientOperationFailureKind = 'clientNotConnected' | 'connectionClosed' | 'connectionDisposed' | 'runtimeConnectionClosed'; @@ -26,14 +26,14 @@ export class CopilotClientStartupConfigChangedError extends Error { } } -export interface ICopilotFailureCorrelation extends IAgentHostInitiatorTelemetry { +export interface ICopilotFailureCorrelation extends IAgentHostEventTelemetry { readonly agentSessionId?: string; readonly chatSessionId?: string; readonly turnId?: string; readonly sdkSessionId?: string; } -type CopilotSessionFailureCorrelation = IAgentHostInitiatorTelemetry & { +type CopilotSessionFailureCorrelation = IAgentHostEventTelemetry & { readonly agentSessionId: string; readonly chatSessionId: string; readonly turnId: string | undefined; @@ -115,7 +115,7 @@ type CopilotClientOperationFailureEvent = ICopilotFailureCorrelation & { callstack: string | undefined; }; -type CopilotClientOperationFailureClassification = IAgentHostInitiatorClassification & { +type CopilotClientOperationFailureClassification = IAgentHostEventClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by detections and recovery telemetry for one Copilot client failure episode.' }; failureKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded category of Copilot client failure that was detected.' }; operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Copilot provider operation that detected the client failure.' }; @@ -133,7 +133,7 @@ type CopilotClientOperationFailureClassification = IAgentHostInitiatorClassifica comment: 'Tracks failures detected while operating an established Copilot client and whether recovery was started.'; }; -type CopilotClientStartupEvent = { +type CopilotClientStartupEvent = IAgentHostCopilotSkuTelemetry & { outcome: CopilotClientStartupOutcome; durationMs: number; attemptNumber: number; @@ -142,7 +142,7 @@ type CopilotClientStartupEvent = { startupExitCode?: number; }; -type CopilotClientStartupClassification = { +type CopilotClientStartupClassification = IAgentHostCopilotSkuClassification & { outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the startup attempt succeeded, failed, or was cancelled during shutdown.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Wall-clock duration of the Copilot client startup attempt in milliseconds.' }; attemptNumber: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'One-based Copilot client startup attempt number within this Agent Host process.' }; @@ -273,7 +273,7 @@ export function reportCopilotClientOperationFailure( }); } -type CopilotClientRecoveryEvent = { +type CopilotClientRecoveryEvent = IAgentHostCopilotSkuTelemetry & { clientFailureId: string; failureKind: CopilotClientOperationFailureKind; durationMs: number; @@ -281,7 +281,7 @@ type CopilotClientRecoveryEvent = { stopSucceeded: boolean; }; -type CopilotClientRecoveryClassification = { +type CopilotClientRecoveryClassification = IAgentHostCopilotSkuClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by detections and recovery telemetry for one Copilot client failure episode.' }; failureKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded category of Copilot client failure that initiated recovery.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent recovering the failed Copilot client.' }; @@ -299,7 +299,7 @@ type CopilotClientRecoveryTurnEvent = CopilotSessionFailureCorrelation & { clientFailureId: string; }; -type CopilotClientRecoveryTurnClassification = IAgentHostInitiatorClassification & { +type CopilotClientRecoveryTurnClassification = IAgentHostEventClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by all telemetry for one Copilot client failure episode.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; @@ -330,7 +330,7 @@ type CopilotSdkSessionErrorEvent = CopilotSessionFailureCorrelation & { callstack: string | undefined; }; -type CopilotSdkSessionErrorClassification = IAgentHostInitiatorClassification & { +type CopilotSdkSessionErrorClassification = IAgentHostEventClassification & { agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host turn identifier, when available.' }; @@ -394,7 +394,7 @@ type CopilotModelCallFailureEvent = CopilotSessionFailureCorrelation & { imagePartsMissingMediaType: number | undefined; }; -type CopilotModelCallFailureClassification = IAgentHostInitiatorClassification & { +type CopilotModelCallFailureClassification = IAgentHostEventClassification & { agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host turn identifier, when available.' }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index 7669c43571dca..f1598c9296c2f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -21,6 +21,7 @@ import { ITelemetryData, ITelemetryService } from '../../../telemetry/common/tel "os_arch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Operating system architecture of the Copilot CLI runtime." }, "node_version": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Node.js version of the Copilot CLI runtime." }, "copilot_plan": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Copilot subscription plan reported by the runtime." }, + "copilotSku": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The raw Copilot entitlement SKU for the authenticated GitHub account." }, "client_type": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Type of client that produced the event." }, "client_name": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Name of the client that produced the event." }, "dev_device_id": { "classification": "EndUserPseudonymizedInformation", "purpose": "BusinessInsight", "comment": "Pseudonymous device identifier supplied by the runtime." }, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 51a414ef8d80a..199a721e8866e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -272,6 +272,11 @@ export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBa readonly model: ModelSelection | undefined; readonly longContextWindow?: number; readonly freeLongContext?: boolean; + /** + * The Auto routing profile to send, already resolved against the gate. Frozen here so the profile + * the runtime receives always matches the one the caller persists. + */ + readonly autoTier?: AutoModeTier; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -281,6 +286,7 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly model: ModelSelection | undefined; readonly longContextWindow?: number; readonly freeLongContext?: boolean; + readonly autoTier?: AutoModeTier; }; } @@ -647,6 +653,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: fallbackPlan.fallback.model, longContextWindow: fallbackPlan.fallback.longContextWindow, freeLongContext: fallbackPlan.fallback.freeLongContext, + autoTier: fallbackPlan.fallback.autoTier, }, fallbackConfig, sandboxConfig); this._sessionOpenTelemetry.sdkResumeFallbackCreated(session); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); @@ -674,9 +681,9 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: plan.model?.id, reasoningEffort: resolveCopilotReasoningEffort(plan.model, this._configurationService, this._logService, plan.sessionId), contextTier: getCopilotContextTier(plan.model, plan.longContextWindow, plan.freeLongContext), - // Create-time only: the runtime then owns the profile, keeping it fixed while the session is - // resident and restoring it on cold resume. - ...toSdkCapiSessionOptions(resolveCopilotAutoTier(plan.model, this._configurationService, this._logService, plan.sessionId)), + // Create-time only. Taken from the plan rather than resolved again, so the profile sent + // always matches the one the caller persists. + ...toSdkCapiSessionOptions(plan.autoTier), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, })); diff --git a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts index 27194fc4e3018..44935508ccba2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts @@ -8,12 +8,12 @@ import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isSubagentSession } from '../../common/state/sessionState.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; type TodoStoreOperation = 'read' | 'write' | 'mixed'; type TodoStoreTarget = 'todos' | 'todo_deps' | 'both'; -type TodoStoreOperationEvent = IAgentHostInitiatorTelemetry & { +type TodoStoreOperationEvent = IAgentHostEventTelemetry & { operation: TodoStoreOperation; target: TodoStoreTarget; toolCallId: string; @@ -22,7 +22,7 @@ type TodoStoreOperationEvent = IAgentHostInitiatorTelemetry & { isSubagentSession: boolean; }; -type TodoStoreOperationClassification = IAgentHostInitiatorClassification & { +type TodoStoreOperationClassification = IAgentHostEventClassification & { operation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation read from, wrote to, or both read from and wrote to todo storage.' }; target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation referenced todo items, todo dependencies, or both.' }; toolCallId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the SQL tool call, used to correlate with generic tool telemetry.' }; diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index 15eb3596c824c..d8900bc782c6c 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME } from '../../common/openSessionLink.js'; import { ArtifactServerToolName, LEGACY_ARTIFACT_SERVER_TOOL_NAMES } from '../../common/serverToolNames.js'; import { parseSessionArtifactInput, SessionArtifactCollection } from '../../common/sessionArtifactCollection.js'; import { readSessionArtifacts, SESSION_ARTIFACT_TYPES, SessionArtifactType, withSessionArtifacts, type ISessionArtifact } from '../../common/sessionArtifacts.js'; @@ -48,7 +50,7 @@ export const artifactServerToolDefinitions: ToolDefinition[] = [ { name: ArtifactServerToolName.AddArtifactOrReference, title: 'Add Artifact or Reference', - description: 'Record an artifact or a reference so it is surfaced next to the chat input. An artifact is something this session produced that is not just an ordinary workspace edit: a pull request or issue it opened, a plan or report file it wrote outside the workspace, or another side effect of its work. A reference is something the session did not produce but the user should look at because of this task: the pull request or commit that introduced a bug, an issue it investigated, or a website worth reading. Set `isArtifact` accordingly. Do not record routine files you merely edited.', + description: 'Record an artifact or a reference so it is surfaced next to the chat input. An artifact is something this session produced that is not just an ordinary workspace edit: a pull request or issue it opened, a plan or report file it wrote outside the workspace, or another side effect of its work. A reference is something the session did not produce but the user should look at because of this task: the pull request or commit that introduced a bug, an issue it investigated, or a website worth reading. Set `isArtifact` accordingly. Do not record routine files you merely edited or sessions and chats created with session-management tools.', inputSchema: addArtifactInputSchema, annotations: { readOnlyHint: false }, }, @@ -164,6 +166,9 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce switch (toolName) { case ArtifactServerToolName.AddArtifactOrReference: { const input = parseSessionArtifactInput(rawArgs, ArtifactServerToolName.AddArtifactOrReference); + if (input.uri && URI.parse(input.uri).scheme === AGENT_HOST_SESSION_LINK_SCHEME) { + throw new Error(`Invalid ${ArtifactServerToolName.AddArtifactOrReference} input: sessions and chats created with session-management tools must not be recorded as artifacts or references.`); + } const result = artifacts.read().add(input, generateUuid); if (!result.added) { return `Already recorded: ${describeArtifact(result.artifact)}`; @@ -201,4 +206,4 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce * The instruction appended to every agent's host instructions while the * artifact tools are enabled. */ -export const ARTIFACT_TOOLS_INSTRUCTION = `Record the notable results of your work with \`${ArtifactServerToolName.AddArtifactOrReference}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits) so they are surfaced next to the chat input. Pass \`isArtifact: true\` for an artifact — something this session produced beyond ordinary workspace edits, such as a pull request or issue you opened, a plan or report file you wrote outside the workspace, or another side effect of your work. Pass \`isArtifact: false\` for a reference — something you did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, an issue you investigated, or a website worth reading. Record each one once, and do not record routine files you merely edited or commits you create unless the user asks for them.`; +export const ARTIFACT_TOOLS_INSTRUCTION = `Record the notable results of your work with \`${ArtifactServerToolName.AddArtifactOrReference}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits) so they are surfaced next to the chat input. Pass \`isArtifact: true\` for an artifact — something this session produced beyond ordinary workspace edits, such as a pull request or issue you opened, a plan or report file you wrote outside the workspace, or another side effect of your work. Pass \`isArtifact: false\` for a reference — something you did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, an issue you investigated, or a website worth reading. Record each one once, and do not record routine files you merely edited, commits you create unless the user asks for them, or sessions and chats created with session-management tools.`; diff --git a/src/vs/platform/agentHost/node/shared/editArcReporter.ts b/src/vs/platform/agentHost/node/shared/editArcReporter.ts index bca064fa86dd3..05488290c9c76 100644 --- a/src/vs/platform/agentHost/node/shared/editArcReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editArcReporter.ts @@ -23,7 +23,11 @@ import { IDiffComputeService } from '../../common/diffComputeService.js'; import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostTelemetryService, isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; + +type IAgentHostEditArcTelemetryEvent = IEditArcTelemetryEvent & IAgentHostEventTelemetry; + +type IAgentHostEditArcTelemetryClassification = IEditArcTelemetryClassification & IAgentHostEventClassification; export interface IEditArcReporterLaunchParams { readonly clientContext?: IAgentHostClientTelemetryContext; @@ -316,7 +320,7 @@ class EditArcReporter extends Disposable { const provider = AgentSession.provider(sessionUri) ?? 'unknown'; const originalLineCounts = new EditArcTracker(this._params.beforeText, this._params.initialEdit).getLineCountInfo(); const currentLineCounts = this._tracker.getLineCountInfo(); - const event: IEditArcTelemetryEvent & IAgentHostInitiatorTelemetry = { + const event: IAgentHostEditArcTelemetryEvent = { ...toInitiatorTelemetry(this._params.clientContext), sourceKeyCleaned: 'source:Chat.applyEdits', extensionId: undefined, @@ -340,7 +344,7 @@ class EditArcReporter extends Disposable { currentLineCount: currentLineCounts.insertedLineCounts, currentDeletedLineCount: currentLineCounts.deletedLineCounts, }; - this._telemetryService.publicLog2('editTelemetry.reportEditArc', event); + this._telemetryService.publicLog2('editTelemetry.reportEditArc', event); if (provider === 'copilotcli' && isAgentHostTelemetryService(this._telemetryService)) { const { didBranchChange, diff --git a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts index aa93350af8946..61c111b738ad6 100644 --- a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts @@ -14,7 +14,7 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isAhpChatChannel, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; import { computeChunkedEditSurvival, computeWholeFileEditSurvival } from './editSurvivalTracker.js'; /** @@ -84,7 +84,7 @@ export class NullEditSurvivalReporterFactory implements IEditSurvivalReporterFac } } -interface IEditSurvivalTelemetryEvent extends IAgentHostInitiatorTelemetry { +interface IEditSurvivalTelemetryEvent extends IAgentHostEventTelemetry { provider: string; modelId: string; toolName: string; @@ -105,7 +105,7 @@ interface IEditSurvivalTelemetryEvent extends IAgentHostInitiatorTelemetry { currentTextLength: number; } -type IEditSurvivalTelemetryClassification = IAgentHostInitiatorClassification & { +type IEditSurvivalTelemetryClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model that produced the edit, e.g. "claude-sonnet-4.5" or "gpt-5-mini". Empty if the host could not determine the per-edit model.' }; toolName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Name of the edit tool that produced the edit, e.g. "Edit", "apply_patch". Empty if unknown.' }; diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 06fc392df17c8..2de99cfa54a49 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -11,6 +11,7 @@ import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; +import { extUriBiasedIgnorePathCase } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -557,6 +558,58 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(sessions.map(s => readSessionExternal(s._meta)), [true]); }); + test('listSessions preserves client-addressed remote working directories across reload', async () => { + const { client, transport } = createClient(); + const remoteDirectory = URI.parse('vscode-remote://ssh-remote+host/workspace'); + const hostDirectory = URI.file('/workspace'); + const summary = { + resource: 'agent-session://copilotcli/remote-1', + provider: 'copilotcli', + title: 'Remote Chat', + status: SessionStatus.Idle, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + workingDirectories: [remoteDirectory.toString(), hostDirectory.toString()], + }; + let liveWorkingDirectories: readonly string[] | undefined; + disposables.add(client.onDidNotification(notification => { + if (notification.type === 'root/sessionAdded') { + liveWorkingDirectories = notification.summary.workingDirectories; + } + })); + transport.fireMessage({ + jsonrpc: '2.0', + method: 'root/sessionAdded', + params: { channel: ROOT_STATE_URI, summary }, + }); + + const resultPromise = client.listSessions(); + const sent = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', + id: sent.id, + result: { + items: [summary], + }, + }); + + const [session] = await resultPromise; + assert.deepStrictEqual({ + liveWorkingDirectories, + liveVisibleInWorkspace: liveWorkingDirectories?.some(directory => extUriBiasedIgnorePathCase.isEqualOrParent(URI.parse(directory), remoteDirectory)), + workingDirectories: session.workingDirectories?.map(uri => uri.toString()), + restoredVisibleInWorkspace: session.workingDirectories?.some(directory => extUriBiasedIgnorePathCase.isEqualOrParent(directory, remoteDirectory)), + }, { + liveWorkingDirectories: summary.workingDirectories, + liveVisibleInWorkspace: true, + workingDirectories: [ + remoteDirectory.toString(), + client.resourceUris.fromAgentHost(hostDirectory).toString(), + ], + restoredVisibleInWorkspace: true, + }); + }); + test('queues requests and notifications until a client transport initializes', async () => { const transport = disposables.add(new TestClientProtocolTransport()); const { client } = createClient(transport); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts index 970f822d8cc7d..bb1246e2c0850 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts @@ -65,6 +65,14 @@ suite('AgentHostCommitOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), []); }); + test('advertises commit for a new session without uncommitted changes', () => { + const provider = createContribution('worktree'); + + const operations = provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }); + + assert.deepStrictEqual(operations?.map(op => op.id), ['commit']); + }); + test('advertises commit on every folder session changeset when there are uncommitted changes', () => { const provider = createContribution('folder'); diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts index c19d4f2cd8175..a56032dc098d7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -15,6 +15,7 @@ import { IAgentHostGitStateService } from '../../common/agentHostGitStateService import { IAgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; import { activateAgentHostContributions } from '../../node/agentHostContributions.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; class FailingChangesetOperationService extends Disposable implements IAgentHostChangesetOperationService { declare readonly _serviceBrand: undefined; @@ -54,6 +55,7 @@ const nullPullRequestStatusService: IAgentHostPullRequestStatusService = { _serviceBrand: undefined, onDidChangePullRequestStatus: Event.None, getPullRequestStatus() { return undefined; }, + markPullRequestMerged() { }, async refresh() { }, dispose() { }, }; @@ -63,12 +65,15 @@ suite('AgentHostContributions', () => { test('disposes earlier registrations when activation fails', () => { const changesetOperationService = disposables.add(new FailingChangesetOperationService()); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); const services = new ServiceCollection( - [IAgentHostStateManager, disposables.add(new AgentHostStateManager(new NullLogService()))], + [IAgentHostStateManager, stateManager], [IAgentHostChangesetOperationService, changesetOperationService], [IAgentHostGitStateService, nullGitStateService], [IAgentHostPullRequestStatusService, nullPullRequestStatusService], - [ILogService, new NullLogService()], + [IAgentConfigurationService, disposables.add(new AgentConfigurationService(stateManager, logService))], + [ILogService, logService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 92354f49343f3..7f413d001aa00 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -84,6 +84,31 @@ suite('AgentHostGitStateService', () => { }); }); + test('keeps pull request state scoped to its pull request', () => { + const pullRequest = 'https://github.com/microsoft/vscode/pull/1'; + const state: ISessionGitHubState = { + pullRequestUrls: [pullRequest], + pullRequestState: 'merged', + pullRequestStateUrl: pullRequest, + }; + + assert.deepStrictEqual({ + same: withMostRecentSessionPullRequest(state, `${pullRequest}/`, 'feature-1'), + different: withMostRecentSessionPullRequest(state, 'https://github.com/microsoft/vscode/pull/2', 'feature-2'), + }, { + same: { + pullRequestUrls: [pullRequest], + pullRequestBranchName: 'feature-1', + pullRequestState: 'merged', + pullRequestStateUrl: pullRequest, + }, + different: { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/2', pullRequest], + pullRequestBranchName: 'feature-2', + }, + }); + }); + test('promotes an initial pull request into the session', () => { const initial = 'https://github.com/microsoft/vscode/pull/1'; const state = withMostRecentRelatedSessionPullRequest({ diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts index 480307d88f5e0..fe9d85a07f417 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts @@ -74,9 +74,10 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { readonly prepareMergeError?: Error; readonly mergeMethod?: string; }, - ): { readonly handler: AgentHostPullRequestLifecycleOperationHandler; readonly recorded: IRecordedCalls; readonly refreshes: string[] } { + ): { readonly handler: AgentHostPullRequestLifecycleOperationHandler; readonly recorded: IRecordedCalls; readonly refreshes: string[]; readonly merged: string[] } { const recorded: IRecordedCalls = { calls: [] }; const refreshes: string[] = []; + const merged: string[] = []; const currentStatus = options?.status === null ? undefined : options?.status ?? status(); const mutations = new class extends mock() { @@ -116,6 +117,7 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { _serviceBrand: undefined, onDidChangePullRequestStatus: Event.None, getPullRequestStatus: () => currentStatus, + markPullRequestMerged: (sessionKey, url) => { merged.push(`${sessionKey}|${url}`); }, refresh: async (sessionKey: string) => { refreshes.push(sessionKey); }, dispose: () => { }, }; @@ -133,7 +135,7 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { gitHubService, new NullLogService(), ); - return { handler, recorded, refreshes }; + return { handler, recorded, refreshes, merged }; } function invoke(handler: AgentHostPullRequestLifecycleOperationHandler): Promise { @@ -141,24 +143,28 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { } test('merges directly with the repository-allowed method', async () => { - const { handler, recorded, refreshes } = createHandler('merge'); + const { handler, recorded, refreshes, merged } = createHandler('merge'); disposables.add({ dispose: () => { } }); await invoke(handler); // The preparation gate runs before the merge, and the status is // refreshed afterwards so the button bar re-derives. - assert.deepStrictEqual({ calls: recorded.calls, refreshed: refreshes.length }, { calls: ['prepareMerge', 'merge:SQUASH'], refreshed: 1 }); + assert.deepStrictEqual({ calls: recorded.calls, refreshed: refreshes.length, merged }, { + calls: ['prepareMerge', 'merge:SQUASH'], + refreshed: 1, + merged: [`${sessionUri}|${pullRequestUrl}`], + }); }); test('enqueues instead of merging when the repository requires a merge queue', async () => { - const { handler, recorded } = createHandler('merge', { + const { handler, recorded, merged } = createHandler('merge', { preparation: preparation({ mergeQueueRequired: true }), }); await invoke(handler); - assert.deepStrictEqual(recorded.calls, ['prepareMerge', 'enqueue']); + assert.deepStrictEqual({ calls: recorded.calls, merged }, { calls: ['prepareMerge', 'enqueue'], merged: [] }); }); test('honours the configured merge method and rejects one the repository forbids', async () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 425d57871edd8..0441df9a391c1 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -24,6 +24,9 @@ import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; import type { IAgentBranchNameGenerator, IAgentBranchNameGeneratorRequest } from '../../node/shared/agentBranchNameGenerator.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import type { AgentMergeControllerState, AgentMergeSessionOverrides } from '../../common/agentMerge.js'; +import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; class TestCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; @@ -206,11 +209,12 @@ function createAuthenticationService(withCopilotToken = false): IAgentHostAuthen }; } -function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; baseBranch?: string; branchPrefix?: string }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; createdBranches: string[]; copilotApiService: TestCopilotApiService; branchNameGenerator: TestBranchNameGenerator } { +function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; enableAgentMerge?: boolean; agentMergeOverrides?: AgentMergeSessionOverrides; agentMergeControllerState?: AgentMergeControllerState; baseBranch?: string; branchPrefix?: string }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; createdBranches: string[]; sessionConfigUpdates: Record[]; copilotApiService: TestCopilotApiService; branchNameGenerator: TestBranchNameGenerator } { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agent:/session'); const createdEvents: string[] = []; const createdBranches: string[] = []; + const sessionConfigUpdates: Record[] = []; stateManager.createSession({ resource: session.toString(), provider: 'copilot', @@ -244,10 +248,22 @@ function setup(disposables: Pick, gitService: TestGitSer stateManager.setSessionMeta(session.toString(), sessionMeta); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); const branchNameGenerator = new TestBranchNameGenerator(); + const configurationService = new class extends mock() { + override getSessionConfigValues(): Record { + return { + ...(options?.agentMergeOverrides ? { [SessionConfigKey.AgentMerge]: { enabled: false, overrides: options.agentMergeOverrides } } : {}), + ...(options?.agentMergeControllerState ? { [SessionConfigKey.AgentMergeController]: options.agentMergeControllerState } : {}), + }; + } + override updateSessionConfig(_session: string, patch: Record): void { + sessionConfigUpdates.push(patch); + } + }(); return { handler: new AgentHostPullRequestOperationHandler( options?.draft ?? false, options?.autoMergeMethod, + options?.enableAgentMerge ?? false, sessionKey => { const state = stateManager.getSessionState(sessionKey); if (state && options?.turns) { @@ -260,10 +276,11 @@ function setup(disposables: Pick, gitService: TestGitSer createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`); createdBranches.push(event.branchName); }, - createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, branchNameGenerator, new NullLogService()), + createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, branchNameGenerator, configurationService, new NullLogService()), session, createdEvents, createdBranches, + sessionConfigUpdates, copilotApiService, branchNameGenerator, }; @@ -309,6 +326,42 @@ suite('AgentHostPullRequestOperationHandler', () => { }); }); + test('enables Agent Merge after creating the pull request, preserves overrides, and clears stale controller state', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const overrides: AgentMergeSessionOverrides = { fixCI: false }; + const { handler, session, createdEvents, sessionConfigUpdates } = setup(disposables, gitService, octoKitService, { + enableAgentMerge: true, + agentMergeOverrides: overrides, + agentMergeControllerState: { + target: { + branchName: 'previous-branch', + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/1', + enabledAt: new Date(1).toISOString(), + commentWatermark: '', + }, + }, + }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + createdEvents, + sessionConfigUpdates, + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123) and enabled Agent Merge.' }, + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/123'], + sessionConfigUpdates: [{ + [SessionConfigKey.AgentMerge]: { + enabled: true, + overrides, + }, + [SessionConfigKey.AgentMergeController]: {}, + }], + }); + }); + test('creates a generated branch before committing when the current branch is the base branch', async () => { const gitService = new TestGitService(); gitService.uncommitted = true; diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index ab2c654bf4a3c..d25bea09e54de 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -15,6 +15,9 @@ import { SessionStatus, type ISessionGitHubState, type ISessionGitState } from ' import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { ChangesetKind } from '../../common/changesetUri.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentMergeConfigKey } from '../../common/agentMerge.js'; const nullGitStateService = new class implements IAgentHostGitStateService { declare readonly _serviceBrand: undefined; @@ -28,11 +31,12 @@ const nullGitStateService = new class implements IAgentHostGitStateService { async attachSessionGitHubPullRequest(): Promise { } }; -function createStatusService(status?: IAgentHostPullRequestStatus): IAgentHostPullRequestStatusService { +function createStatusService(status?: IAgentHostPullRequestStatus, onDidChangePullRequestStatus = Event.None): IAgentHostPullRequestStatusService { return { _serviceBrand: undefined, - onDidChangePullRequestStatus: Event.None, + onDidChangePullRequestStatus, getPullRequestStatus: () => status, + markPullRequestMerged: () => { }, refresh: async () => { }, dispose: () => { }, }; @@ -69,7 +73,7 @@ const pullRequestForBranch: ISessionGitHubState = { suite('AgentHostPullRequestOperationContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree'): AgentHostPullRequestOperationContribution { + function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None, agentMergeEnabled = false): AgentHostPullRequestOperationContribution { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); if (isolation) { stateManager.createSession({ @@ -86,11 +90,17 @@ suite('AgentHostPullRequestOperationContribution', () => { values: { [SessionConfigKey.Isolation]: isolation }, }); } + const configurationService = new class extends mock() { + override getRootValue(_schema: never, key: string) { + return (key === AgentMergeConfigKey.Enabled ? agentMergeEnabled : undefined) as never; + } + }(); return disposables.add(new AgentHostPullRequestOperationContribution( stateManager, disposables.add(new InstantiationService()), nullGitStateService, - createStatusService(status), + createStatusService(status, onDidChangePullRequestStatus), + configurationService, new NullLogService(), )); } @@ -103,6 +113,14 @@ suite('AgentHostPullRequestOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-draft-pr']); }); + test('advertises Create PR and Enable Agent Merge as the last Create PR option when Agent Merge is enabled', () => { + const provider = createContribution(undefined, undefined, Event.None, true); + + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); + + assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-pr-agent-merge', 'create-draft-pr']); + }); + test('does not advertise PR operations for folder sessions with outgoing changes', () => { const provider = createContribution(undefined, 'folder'); @@ -162,4 +180,5 @@ suite('AgentHostPullRequestOperationContribution', () => { noAutoMerge: undefined, }); }); + }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts index b14cdece213ae..6d794cc64d4b8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts @@ -16,7 +16,7 @@ import type { IPullRequestResources } from '../../../github/common/pullRequestRe import { mock } from '../../../../base/test/common/mock.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; -import { SessionStatus, withSessionGitHubState, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { readSessionGitHubState, SessionStatus, withSessionGitHubState, withSessionGitState, type ISessionGitHubState, type SessionSummary } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; @@ -83,20 +83,31 @@ class TestPullRequestResources implements IPullRequestResources { readonly subscribed: PullRequestRef[] = []; disposedCount = 0; private _snapshot = observableValue('snapshot', undefined); + private _nextSubscriptionSnapshot: PullRequestSnapshot | undefined; + refreshHandler: (() => Promise) | undefined; get liveSubscriptions(): number { return this.subscribed.length - this.disposedCount; } subscribePullRequest(ref: PullRequestRef): PullRequestSubscription { this.subscribed.push(ref); - this._snapshot.set(snapshot(ref), undefined); + this._snapshot.set(this._nextSubscriptionSnapshot ?? snapshot(ref), undefined); + this._nextSubscriptionSnapshot = undefined; return { resource: { ref, snapshot: this._snapshot as never }, update: () => { }, - refresh: async () => { }, + refresh: async () => this.refreshHandler?.(), dispose: () => { this.disposedCount++; }, } as PullRequestSubscription; } + setSnapshot(value: PullRequestSnapshot): void { + this._snapshot.set(value, undefined); + } + + setNextSubscriptionSnapshot(value: PullRequestSnapshot): void { + this._nextSubscriptionSnapshot = value; + } + invalidatePullRequest(): void { } clear(): void { } } @@ -180,6 +191,7 @@ suite('AgentHostPullRequestStatusService', () => { const subscriptions = disposables.add(new TestChangesetSubscriptions()); const credentials = disposables.add(new TestCredentials()); const resources = new TestPullRequestResources(); + const gitHubStates: ISessionGitHubState[] = []; const gitHubService = new class extends mock() { override readonly credentials = credentials; override readonly pullRequests = resources; @@ -187,6 +199,14 @@ suite('AgentHostPullRequestStatusService', () => { const gitStateService = new class extends mock() { override readonly onDidRefreshSessionGitState = Event.None; override readonly onDidChangeSessionGitHubState = Event.None; + override async setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise { + gitHubStates.push(state); + const currentMeta = stateManager.getSessionState(sessionKey)?._meta; + stateManager.setSessionMeta(sessionKey, withSessionGitHubState(currentMeta, { + ...readSessionGitHubState(currentMeta), + ...state, + })); + } }(); const service = disposables.add(new AgentHostPullRequestStatusService( stateManager, @@ -205,7 +225,7 @@ suite('AgentHostPullRequestStatusService', () => { { pullRequestUrls: [pullRequestUrl], pullRequestBranchName: 'feature' }, )); - return { service, stateManager, subscriptions, credentials, resources, session }; + return { service, stateManager, subscriptions, credentials, resources, gitHubStates, session }; } test('watches only while a client is subscribed to the session changes', async () => { @@ -231,6 +251,113 @@ suite('AgentHostPullRequestStatusService', () => { }); }); + test('optimistically records a successful merge in host pull request state', async () => { + const { service, subscriptions, resources, gitHubStates, session } = createHarness(); + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + + service.markPullRequestMerged(session, pullRequestUrl); + + assert.deepStrictEqual({ + status: service.getPullRequestStatus(session)?.state, + gitHubState: gitHubStates.at(-1), + }, { + status: 'merged', + gitHubState: { + pullRequestState: 'merged', + pullRequestStateUrl: pullRequestUrl, + }, + }); + }); + + test('records a successful merge after its pull request watch was disposed', async () => { + const { service, gitHubStates, session } = createHarness(); + + service.markPullRequestMerged(session, pullRequestUrl); + + assert.deepStrictEqual({ + status: service.getPullRequestStatus(session), + gitHubState: gitHubStates.at(-1), + }, { + status: undefined, + gitHubState: { + pullRequestState: 'merged', + pullRequestStateUrl: pullRequestUrl, + }, + }); + }); + + test('does not downgrade a merged pull request from a retained loading snapshot', async () => { + const { service, subscriptions, resources, session } = createHarness(); + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + service.markPullRequestMerged(session, pullRequestUrl); + + const retainedOpenSnapshot = snapshot(resources.subscribed[0]); + resources.setSnapshot({ + ...retainedOpenSnapshot, + core: { ...retainedOpenSnapshot.core, status: 'loading', complete: false }, + }); + const whileLoading = service.getPullRequestStatus(session)?.state; + resources.setSnapshot(retainedOpenSnapshot); + + assert.deepStrictEqual({ + whileLoading, + afterRefresh: service.getPullRequestStatus(session)?.state, + }, { + whileLoading: 'merged', + afterRefresh: 'open', + }); + }); + + test('waits for a fresh refresh before reconciling a recreated merged watch', async () => { + const { service, subscriptions, resources, session } = createHarness(); + const channel = `${session}/changes`; + subscriptions.addSubscription(session, channel); + await waitForWatch(resources); + service.markPullRequestMerged(session, pullRequestUrl); + subscriptions.removeSubscription(session, channel); + + const refresh = new DeferredPromise(); + resources.refreshHandler = () => refresh.p; + subscriptions.addSubscription(session, channel); + await waitForWatch(resources); + const whileRefreshing = service.getPullRequestStatus(session); + refresh.complete(); + await pump(); + + assert.deepStrictEqual({ + whileRefreshing, + afterRefresh: service.getPullRequestStatus(session)?.state, + }, { + whileRefreshing: undefined, + afterRefresh: 'open', + }); + }); + + test('does not apply persisted merged state from another GitHub host', async () => { + const { service, stateManager, subscriptions, resources, session } = createHarness(); + const retainedOpenSnapshot = snapshot({ ...account, owner: 'octo', repo: 'repo', number: 7 }); + resources.setNextSubscriptionSnapshot({ + ...retainedOpenSnapshot, + core: { ...retainedOpenSnapshot.core, status: 'loading', complete: false }, + }); + stateManager.setSessionMeta(session, withSessionGitHubState( + stateManager.getSessionState(session)?._meta, + { + pullRequestUrls: [pullRequestUrl], + pullRequestBranchName: 'feature', + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.example.com/octo/repo/pull/7', + }, + )); + + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + + assert.strictEqual(service.getPullRequestStatus(session)?.state, 'open'); + }); + test('does not install a watch when the session stopped being eligible mid-sync', async () => { const { service, subscriptions, credentials, resources, session } = createHarness(); diff --git a/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts index 6cd0bef7884c1..1f15c8957d06b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts @@ -106,6 +106,28 @@ suite('AgentHostRestrictedTelemetrySender', () => { }); }); + test('common properties are added to and removed from standard and enhanced envelopes', () => { + const { sender, envelopes } = createSender(); + sender.setRestrictedTelemetryEnabled(true); + + sender.setCommonProperty('copilotSku', 'copilot_for_business_seat'); + sender.sendGHTelemetryEvent('standard'); + sender.sendEnhancedGHTelemetryEvent('enhanced'); + sender.setCommonProperty('copilotSku', undefined); + sender.sendGHTelemetryEvent('standard'); + sender.sendEnhancedGHTelemetryEvent('enhanced'); + + assert.deepStrictEqual(envelopes.map(envelope => ({ + hasCopilotSku: Object.hasOwn(envelope.data.baseData.properties, 'copilotSku'), + copilotSku: envelope.data.baseData.properties.copilotSku, + })), [ + { hasCopilotSku: true, copilotSku: 'copilot_for_business_seat' }, + { hasCopilotSku: true, copilotSku: 'copilot_for_business_seat' }, + { hasCopilotSku: false, copilotSku: undefined }, + { hasCopilotSku: false, copilotSku: undefined }, + ]); + }); + test('oversized enhanced telemetry is not posted when property bytes are below the limit', () => { const logService = new RecordingLogService(); const { sender, posts } = createSender(logService); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index 17f9a188cedcd..ad267d8e2438d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -51,8 +51,12 @@ class TestTelemetryService implements ITelemetryService { } setExperimentProperty(): void { } - setCommonProperty(name: string, value: string | boolean): void { - this.commonProperties[name] = value; + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this.commonProperties[name]; + } else { + this.commonProperties[name] = value; + } } } @@ -93,8 +97,12 @@ class TestRestrictedSink implements IAgentHostRestrictedTelemetry { setInternalTelemetryContext(context: IAgentHostInternalTelemetryContext | undefined): void { this.internalContexts.push(context); } - setCommonProperty(name: string, value: string | boolean): void { - this.commonProperties[name] = value; + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this.commonProperties[name]; + } else { + this.commonProperties[name] = value; + } } } @@ -239,16 +247,24 @@ suite('AgentHostTelemetryService', () => { }); }); - test('forwards common properties to standard and restricted telemetry', () => { + test('forwards setting and clearing common properties to standard and restricted telemetry', () => { const delegate = new TestTelemetryService(); const sink = new TestRestrictedSink(); const service = disposables.add(new AgentHostTelemetryService(delegate, sink)); service.setCommonProperty('copilotSku', 'copilot_for_business_seat'); - - assert.deepStrictEqual({ delegate: delegate.commonProperties, restricted: sink.commonProperties }, { - delegate: { copilotSku: 'copilot_for_business_seat' }, - restricted: { copilotSku: 'copilot_for_business_seat' }, + const afterSet = { delegate: { ...delegate.commonProperties }, restricted: { ...sink.commonProperties } }; + service.setCommonProperty('copilotSku', undefined); + + assert.deepStrictEqual({ afterSet, afterClear: { delegate: delegate.commonProperties, restricted: sink.commonProperties } }, { + afterSet: { + delegate: { copilotSku: 'copilot_for_business_seat' }, + restricted: { copilotSku: 'copilot_for_business_seat' }, + }, + afterClear: { + delegate: {}, + restricted: {}, + }, }); }); diff --git a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts index 3929bbd602e83..3a48e0270d5a7 100644 --- a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts @@ -4,13 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { getErrorMessage } from '../../../../base/common/errors.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; import { ArtifactServerToolName } from '../../common/serverToolNames.js'; -import { artifactServerToolDefinitions, createArtifactServerToolGroup } from '../../node/shared/artifactServerTools.js'; +import { buildDefaultChatUri, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { ARTIFACT_TOOLS_INSTRUCTION, artifactServerToolDefinitions, createArtifactServerToolGroup } from '../../node/shared/artifactServerTools.js'; import { getServerToolDisplay } from '../../node/shared/serverToolGroups.js'; suite('Artifact Server Tools', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); const group = createArtifactServerToolGroup(); const display = (toolName: string, args: unknown, result?: { text: string; success: boolean }) => group.getDisplay?.(toolName, args, result); @@ -38,6 +42,53 @@ suite('Artifact Server Tools', () => { }); }); + test('excludes session-management results from artifacts', () => { + const addDefinition = artifactServerToolDefinitions.find(definition => definition.name === ArtifactServerToolName.AddArtifactOrReference); + + assert.deepStrictEqual({ + definition: addDefinition?.description?.includes('sessions and chats created with session-management tools'), + instruction: ARTIFACT_TOOLS_INSTRUCTION.includes('sessions and chats created with session-management tools'), + }, { + definition: true, + instruction: true, + }); + }); + + test('rejects session-management links during execution', async () => { + const sessionUri = 'copilot:/caller'; + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: sessionUri, + provider: 'copilot', + title: 'Caller', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + let persisted = false; + const group = createArtifactServerToolGroup({ + isEnabled: () => true, + persist: () => persisted = true, + }); + + let errorMessage: string | undefined; + try { + await group.execute(stateManager, { sessionUri, chatUri: buildDefaultChatUri(sessionUri), turnId: 'turn-1' }, ArtifactServerToolName.AddArtifactOrReference, { + type: 'resource', + label: 'Spawned session', + isArtifact: true, + uri: 'agent-host-session://copilot/spawned', + }); + } catch (error) { + errorMessage = getErrorMessage(error); + } + + assert.deepStrictEqual({ errorMessage, persisted }, { + errorMessage: 'Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.', + persisted: false, + }); + }); + test('names what a completed removal actually removed', () => { const removed = (text: string) => display(ArtifactServerToolName.RemoveArtifactOrReference, { id: 'id-1' }, { text, success: true })?.pastTenseMessage; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 0eb2f4a495476..b9155b6cbf610 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -354,6 +354,7 @@ class TestCopilotApiService implements ICopilotApiService { userLogin: string | undefined; readonly restrictedTelemetryContexts = new Map(); readonly restrictedTelemetryContextCalls: string[] = []; + resolveCopilotSkuHandler: (githubToken: string) => Promise = async () => undefined; messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator; messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; @@ -376,6 +377,7 @@ class TestCopilotApiService implements ICopilotApiService { } async resolveApiEndpoint() { return this.apiEndpoint; } async resolveUserLogin() { return this.userLogin; } + async resolveCopilotSku(githubToken: string): Promise { return this.resolveCopilotSkuHandler(githubToken); } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -429,6 +431,9 @@ interface ITestCopilotModelInfo { readonly billing?: CopilotModelInfo['billing']; readonly modelPickerCategory?: CopilotModelInfo['modelPickerCategory']; readonly modelPickerPriceCategory?: CopilotModelInfo['modelPickerPriceCategory']; + readonly warningText?: CopilotModelInfo['warningText']; + readonly infoMessages?: CopilotModelInfo['infoMessages']; + readonly warningMessages?: CopilotModelInfo['warningMessages']; readonly supportedReasoningEfforts?: CopilotModelInfo['supportedReasoningEfforts']; } @@ -469,6 +474,9 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): CopilotModelInfo { ...(model.billing ? { billing: model.billing } : {}), ...(model.modelPickerCategory ? { modelPickerCategory: model.modelPickerCategory } : {}), ...(model.modelPickerPriceCategory ? { modelPickerPriceCategory: model.modelPickerPriceCategory } : {}), + ...(model.warningText ? { warningText: model.warningText } : {}), + ...(model.infoMessages ? { infoMessages: model.infoMessages } : {}), + ...(model.warningMessages ? { warningMessages: model.warningMessages } : {}), ...(model.supportedReasoningEfforts ? { supportedReasoningEfforts: model.supportedReasoningEfforts } : {}), }; } @@ -568,11 +576,18 @@ class RecordingTelemetryService extends NullTelemetryServiceShape { readonly events: Array<{ eventName: string; data: unknown }> = []; readonly errorEvents: Array<{ eventName: string; data: unknown }> = []; readonly experimentProperties: Record = {}; + readonly commonPropertyUpdates: Array<{ name: string; value: string | boolean | undefined }> = []; override setExperimentProperty(name?: string, value?: string): void { this.experimentProperties[name ?? ''] = value ?? ''; } + override setCommonProperty(name?: string, value?: string | boolean): void { + if (name) { + this.commonPropertyUpdates.push({ name, value }); + } + } + override publicLog2(eventName?: string, data?: unknown): void { this.events.push({ eventName: eventName ?? '', data }); } @@ -2078,6 +2093,35 @@ suite('CopilotAgent', () => { } }); + test('updates Copilot SKU telemetry across authentication changes and ignores stale resolution', async () => { + const client = new TestCopilotClient([]); + const copilotApiService = new TestCopilotApiService(); + const telemetryService = new RecordingTelemetryService(); + copilotApiService.resolveCopilotSkuHandler = async token => token === 'token-a' ? 'sku-a' : 'sku-b'; + const agent = createTestAgent(disposables, { copilotClient: client, copilotApiService, telemetryService }); + try { + await agent.authenticate('https://api.github.com', 'token-a'); + const staleResolution = new DeferredPromise(); + copilotApiService.resolveCopilotSkuHandler = token => token === 'token-a' ? staleResolution.p : Promise.resolve('sku-b'); + const staleResolutionCall = agent['_resolveCopilotSku']('token-a'); + + await agent.authenticate('https://api.github.com', 'token-b'); + staleResolution.complete('stale-sku-a'); + await staleResolutionCall; + await agent.authenticate('https://api.github.com', ''); + + assert.deepStrictEqual(telemetryService.commonPropertyUpdates.filter(update => update.name === 'copilotSku'), [ + { name: 'copilotSku', value: undefined }, + { name: 'copilotSku', value: 'sku-a' }, + { name: 'copilotSku', value: undefined }, + { name: 'copilotSku', value: 'sku-b' }, + { name: 'copilotSku', value: undefined }, + ]); + } finally { + await disposeAgent(agent); + } + }); + test('updates every live session after a changed auth token without restarting an unchanged proxy', async () => { const client = new TestCopilotClient([], [{ id: 'gpt-4o', @@ -4853,7 +4897,7 @@ suite('CopilotAgent', () => { } }); - test('models include picker and promo metadata when the SDK provides it', async () => { + test('models include picker, notice, and promo metadata when the SDK provides it', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', @@ -4878,6 +4922,16 @@ suite('CopilotAgent', () => { }, modelPickerCategory: 'powerful', modelPickerPriceCategory: 'medium', + warningText: { + dataRetention: 'Prompts are retained for 30 days.', + }, + infoMessages: [ + { code: 'model_pending_deprecation', message: 'Claude Sonnet will be retired soon.' }, + { code: 'model_relocated', message: 'Claude Sonnet now serves from a new region.' }, + ], + warningMessages: [ + { code: 'model_degraded', message: 'Claude Sonnet is currently degraded.' }, + ], }]), }); try { @@ -4894,6 +4948,15 @@ suite('CopilotAgent', () => { longContextOutputCost: 22.5, priceCategory: 'medium', category: 'powerful', + warningText: { + data_retention: 'Prompts are retained for 30 days.', + model_pending_deprecation: 'Claude Sonnet will be retired soon.', + model_degraded: 'Claude Sonnet is currently degraded.', + }, + infoText: { + model_relocated: 'Claude Sonnet now serves from a new region.', + }, + rowWarning: 'Claude Sonnet is currently degraded.', promo: { id: 'summer-sale', discountPercent: 25, @@ -10053,6 +10116,38 @@ suite('CopilotAgent', () => { } }); + test('drops a provisional Auto routing profile when the gate turns off before the first send', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([], [{ id: 'auto', name: 'Auto' }]); + client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + const { agent, configurationService } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: client, + rootConfig: { [CopilotCliConfigKey.AutoModeTiers]: true }, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, m => m.length > 0); + const session = AgentSession.uri('copilotcli', 'auto-tier-provisional'); + const chat = defaultChatUri(session); + const result = await provisionSession(agent, { + session, + workingDirectories: [URI.file('/workspace')], + model: { id: 'auto', config: { tier: 'intelligence' } }, + }); + + // Still provisional, so the launcher has not run. With the gate off it omits + // `capi.autoTier`, so persisting the selection would claim a profile never sent. + configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTiers]: false }); + await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); + + const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); + assert.deepStrictEqual(JSON.parse(stored ?? 'null'), { id: 'auto' }); + } finally { + await disposeAgent(agent); + } + }); + test('changeAgent resolves and applies the agent to the targeted chat, and clears it with undefined', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index cc704cbd21c92..1d5cb490ba50a 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -22,7 +22,7 @@ import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPlugin import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels } from '../../common/reasoningEffort.js'; -import { autoModeTiers } from '../../common/autoModeTiers.js'; +import { autoModeTiers, type AutoModeTier } from '../../common/autoModeTiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { CustomizationType, McpServerStatus, type ClientPluginCustomization, type ModelSelection } from '../../common/state/protocol/state.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -1528,7 +1528,7 @@ suite('CopilotSessionLauncher auto tier', () => { ensureNoDisposablesAreLeakedInTestSuite(); /** Launches a session and returns the `capi` options the SDK was called with. */ - async function capiOptionsFor(kind: 'create' | 'resume', model: ModelSelection | undefined, rootValues: Partial>): Promise { + async function capiOptionsFor(kind: 'create' | 'resume', autoTier: AutoModeTier | undefined): Promise { let capi: unknown = 'not-called'; const session = { sessionId: 'session-1', @@ -1546,7 +1546,7 @@ suite('CopilotSessionLauncher auto tier', () => { return session; }, } as unknown as Pick; - const launcher = createTestLauncher(undefined, rootValues); + const launcher = createTestLauncher(); const base = { client, sessionId: 'session-1', @@ -1558,8 +1558,8 @@ suite('CopilotSessionLauncher auto tier', () => { githubToken: undefined, }; const plan: CopilotSessionLaunchPlan = kind === 'create' - ? { ...base, kind: 'create', model } - : { ...base, kind: 'resume', fallback: { model } }; + ? { ...base, kind: 'create', model: { id: 'auto' }, autoTier } + : { ...base, kind: 'resume', fallback: { model: { id: 'auto' }, autoTier } }; const sessions = new DisposableStore(); try { @@ -1571,21 +1571,17 @@ suite('CopilotSessionLauncher auto tier', () => { return capi; } - test('sends the profile on create only, and only while the gate is on', async () => { - const auto: ModelSelection = { id: 'auto', config: { tier: 'intelligence' } }; - + test('sends the plan profile verbatim on create, and never on resume', async () => { assert.deepStrictEqual( [ - await capiOptionsFor('create', auto, { [CopilotCliConfigKey.AutoModeTiers]: true }), - // Gate off: omitted entirely, so a runtime without the contract never sees the field. - await capiOptionsFor('create', auto, {}), - // No selection, and a profile left on a concrete model, both leave routing alone. - await capiOptionsFor('create', { id: 'auto' }, { [CopilotCliConfigKey.AutoModeTiers]: true }), - await capiOptionsFor('create', { id: 'gpt-5', config: { tier: 'intelligence' } }, { [CopilotCliConfigKey.AutoModeTiers]: true }), + // Sent exactly as frozen. The launcher must not resolve it again against the live gate. + await capiOptionsFor('create', 'intelligence'), + // No profile: omitted entirely, so a runtime without the contract never sees the field. + await capiOptionsFor('create', undefined), // Resume keeps whatever profile the runtime journaled for the session. - await capiOptionsFor('resume', auto, { [CopilotCliConfigKey.AutoModeTiers]: true }), + await capiOptionsFor('resume', 'intelligence'), ], - [{ autoTier: 'intelligence' }, undefined, undefined, undefined, undefined] + [{ autoTier: 'intelligence' }, undefined, undefined] ); }); }); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 12dba9d4fb1d6..a604525d9f161 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -58,6 +58,7 @@ export enum BrowserViewCommandId { ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, + ClearAgentStorage = `${commandPrefix}.clearAgentStorage`, // Find in page ShowFind = `${commandPrefix}.showFind`, @@ -431,29 +432,34 @@ export interface IBrowserViewFindInPageResult { export enum BrowserViewStorageScope { Global = 'global', Workspace = 'workspace', - Ephemeral = 'ephemeral' + Ephemeral = 'ephemeral', + Agent = 'agent' } export type IBrowserViewSessionOptions = | { readonly scope: BrowserViewStorageScope.Global } | { readonly scope: BrowserViewStorageScope.Workspace } + | { readonly scope: BrowserViewStorageScope.Ephemeral } | { - readonly scope: BrowserViewStorageScope.Ephemeral; + readonly scope: BrowserViewStorageScope.Agent; /** Views with the same affinity share one in-memory browser session. */ readonly affinity?: string; }; +export function isInMemoryStorageScope(scope: BrowserViewStorageScope): boolean { + return scope === BrowserViewStorageScope.Ephemeral || scope === BrowserViewStorageScope.Agent; +} + /** Selects an existing browser context by ID or resolves one from storage options. */ export type BrowserViewSessionSelector = string | IBrowserViewSessionOptions; -export function getAgentBrowserViewCreationDefaults(sessionId: string) { +export function getAgentBrowserViewCreationDefaults(sessionId: string, storageAffinity?: string) { return { owner: { type: 'agent', sessionId } as const, initialAudiences: [{ type: 'agent' }] as const, - session: { - scope: BrowserViewStorageScope.Ephemeral, - affinity: sessionId - } as const + session: storageAffinity === undefined + ? { scope: BrowserViewStorageScope.Agent } as const + : { scope: BrowserViewStorageScope.Agent, affinity: storageAffinity } as const }; } @@ -505,6 +511,7 @@ export interface IBrowserViewService { onDynamicDidKeyCommand(id: string): Event; onDynamicDidChangeTitle(id: string): Event; onDynamicDidChangeFavicon(id: string): Event; + onDynamicDidChangeOwner(id: string): Event; onDynamicDidFindInPage(id: string): Event; onDynamicDidClose(id: string): Event; onDynamicDidSelectElement(id: string): Event; @@ -537,6 +544,11 @@ export interface IBrowserViewService { */ destroyBrowserView(id: string): Promise; + /** + * Update the owner of an existing browser view. + */ + setOwner(id: string, owner: IBrowserViewOwner): Promise; + /** * Get the state of an existing browser view by ID, or throw if it doesn't exist * @param id The browser view identifier diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 565a496a8ec64..a179fdd4dda57 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -12,6 +12,8 @@ export const IPlaywrightService = createDecorator('playwrigh */ export interface IPlaywrightServiceInitializeOptions { readonly windowId: number; + /** Whether Playwright groups isolate Agent storage by their session ID. */ + readonly useSessionStorageAffinity: boolean; } export interface IInvokeFunctionResult { diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index bf4eae2f6a4a9..9184d834da496 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -57,7 +57,7 @@ export class BrowserSession { * - Global scope -> `"global"` * - Workspace scope -> `"workspace:${workspaceId}"` * - Ephemeral per-view -> `"ephemeral:${viewId}"` - * - Ephemeral affinity -> `"ephemeral-affinity:${affinityHash}"` + * - Agent scope -> `"agent:${identityHash}"` * - Custom type -> `"${type}:${viewId}"` */ private static readonly _byId = new Map>(); @@ -138,7 +138,7 @@ export class BrowserSession { * Get or create an ephemeral session for the given view or target ID. */ static getOrCreateEphemeral(instantiationService: IInstantiationService, viewId: string, type?: string): BrowserSession { - if (type === 'workspace' || type === 'ephemeral') { + if (type === 'workspace' || type === 'ephemeral' || type === 'agent') { throw new Error(`Cannot create session with reserved type '${type}'`); } @@ -148,11 +148,22 @@ export class BrowserSession { ?? instantiationService.createInstance(BrowserSession, sessionId, electronSession, BrowserViewStorageScope.Ephemeral); } - private static getOrCreateEphemeralForAffinity(instantiationService: IInstantiationService, affinity: string): BrowserSession { - const affinityHash = createHash('sha256').update(affinity).digest('hex'); - const electronSession = session.fromPartition(`vscode-browser-affinity-${affinityHash}`); + /** Get or create an in-memory agent session by affinity, workspace, or window. */ + static getOrCreateAgent(instantiationService: IInstantiationService, workspaceId: string | undefined, affinity?: string, windowId?: number): BrowserSession { + let identity: string; + if (affinity !== undefined) { + identity = `affinity:${affinity}`; + } else if (workspaceId !== undefined) { + identity = `workspace:${workspaceId}`; + } else if (windowId !== undefined) { + identity = `window:${windowId}`; + } else { + throw new Error('Agent browser sessions require an affinity, workspace, or window'); + } + const identityHash = createHash('sha256').update(identity).digest('hex'); + const electronSession = session.fromPartition(`vscode-browser-agent-${identityHash}`); return BrowserSession._bySession.get(electronSession) - ?? instantiationService.createInstance(BrowserSession, `ephemeral-affinity:${affinityHash}`, electronSession, BrowserViewStorageScope.Ephemeral); + ?? instantiationService.createInstance(BrowserSession, `agent:${identityHash}`, electronSession, BrowserViewStorageScope.Agent); } /** @@ -177,6 +188,7 @@ export class BrowserSession { options: IBrowserViewSessionOptions, workspaceStorageHome: URI, workspaceId?: string, + windowId?: number, ): BrowserSession { switch (options.scope) { case BrowserViewStorageScope.Global: @@ -187,9 +199,9 @@ export class BrowserSession { } return BrowserSession.getOrCreateEphemeral(instantiationService, viewId); case BrowserViewStorageScope.Ephemeral: - return options.affinity !== undefined - ? BrowserSession.getOrCreateEphemeralForAffinity(instantiationService, options.affinity) - : BrowserSession.getOrCreateEphemeral(instantiationService, viewId); + return BrowserSession.getOrCreateEphemeral(instantiationService, viewId); + case BrowserViewStorageScope.Agent: + return BrowserSession.getOrCreateAgent(instantiationService, workspaceId, options.affinity, windowId); } } diff --git a/src/vs/platform/browserView/electron-main/browserSessionHistory.ts b/src/vs/platform/browserView/electron-main/browserSessionHistory.ts index 94a649a98f27a..3887b29377035 100644 --- a/src/vs/platform/browserView/electron-main/browserSessionHistory.ts +++ b/src/vs/platform/browserView/electron-main/browserSessionHistory.ts @@ -13,7 +13,7 @@ import { ISerializedBrowserFaviconsSnapshot, ISerializedBrowserHistoryEntriesSnapshot, } from '../common/browserHistory.js'; -import { BrowserViewStorageScope, IBrowserViewStorageKeys } from '../common/browserView.js'; +import { IBrowserViewStorageKeys, isInMemoryStorageScope } from '../common/browserView.js'; import type { BrowserSession } from './browserSession.js'; const FLUSH_INTERVAL_MS = 2000; @@ -54,7 +54,7 @@ export class BrowserSessionHistory extends Disposable implements IBrowserSession constructor(session: BrowserSession) { super(); - this.storageKeys = session.storageScope === BrowserViewStorageScope.Ephemeral + this.storageKeys = isInMemoryStorageScope(session.storageScope) ? {} : { history: `browser.history.entries.${session.id}`, diff --git a/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts index a30b3b3d5edd1..a30091a1be2e7 100644 --- a/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts +++ b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts @@ -22,7 +22,7 @@ import { isAlwaysAllowedPermission, toOriginKey, } from '../common/browserPermissions.js'; -import { BrowserViewStorageScope, IBrowserViewPermissionRequestEvent, IBrowserViewStorageKeys } from '../common/browserView.js'; +import { IBrowserViewPermissionRequestEvent, IBrowserViewStorageKeys, isInMemoryStorageScope } from '../common/browserView.js'; import type { BrowserSession } from './browserSession.js'; /** Time the main process waits for a prompt answer before a non-persisted deny. */ @@ -146,7 +146,7 @@ export class BrowserSessionPermissions extends Disposable implements IBrowserSes constructor(session: BrowserSession) { super(); - this.storageKeys = session.storageScope === BrowserViewStorageScope.Ephemeral + this.storageKeys = isInMemoryStorageScope(session.storageScope) ? {} : { permissions: `browser.permissions.${session.id}` }; diff --git a/src/vs/platform/browserView/electron-main/browserSessionTrust.ts b/src/vs/platform/browserView/electron-main/browserSessionTrust.ts index 01c82a072bcad..95ed065c84e8e 100644 --- a/src/vs/platform/browserView/electron-main/browserSessionTrust.ts +++ b/src/vs/platform/browserView/electron-main/browserSessionTrust.ts @@ -5,7 +5,7 @@ import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; -import { IBrowserViewCertificateError } from '../common/browserView.js'; +import { IBrowserViewCertificateError, isInMemoryStorageScope } from '../common/browserView.js'; import type { BrowserSession } from './browserSession.js'; /** Key used to store trusted certificate data in the application storage. */ @@ -201,8 +201,8 @@ export class BrowserSessionTrust implements IBrowserSessionTrust { * first call; subsequent calls are no-ops. */ connectStorage(storage: IApplicationStorageMainService): void { - if (this._storage) { - return; // already connected + if (this._storage || isInMemoryStorageScope(this._session.storageScope)) { + return; } this._storage = storage; this.readStorage(); diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index e3653019c4665..3d5e5a67985d6 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -57,6 +57,7 @@ export class BrowserView extends Disposable { readonly inspector: BrowserViewInspector; private _ownerWindow: ICodeWindow; + private _owner: IBrowserViewOwner; private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; private _audiences: readonly IBrowserViewAudience[] = []; @@ -99,6 +100,9 @@ export class BrowserView extends Disposable { private readonly _onDidChangeFavicon = this._register(new Emitter()); readonly onDidChangeFavicon: Event = this._onDidChangeFavicon.event; + private readonly _onDidChangeOwner = this._register(new Emitter()); + readonly onDidChangeOwner: Event = this._onDidChangeOwner.event; + private readonly _onDidFindInPage = this._register(new Emitter()); readonly onDidFindInPage: Event = this._onDidFindInPage.event; @@ -120,10 +124,10 @@ export class BrowserView extends Disposable { constructor( public readonly id: string, public readonly hostWindowId: number, - public readonly owner: IBrowserViewOwner, + owner: IBrowserViewOwner, public readonly associatedResource: URI | undefined, public readonly session: BrowserSession, - private readonly _createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, editorOptions: IBrowserViewEditorOpenOptions) => BrowserView, + private readonly _createChildView: (owner: IBrowserViewOwner, url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, editorOptions: IBrowserViewEditorOpenOptions) => BrowserView, openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @@ -132,6 +136,7 @@ export class BrowserView extends Disposable { @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); + this._owner = owner; const webPreferences: Electron.WebPreferences = { ...options?.webPreferences, @@ -204,7 +209,7 @@ export class BrowserView extends Disposable { } })()); - const childView = this._createChildView(details.url, options, { + const childView = this._createChildView(this.owner, details.url, options, { pinned: true, background: location === NewPageLocation.Background, parentViewId: id, @@ -591,6 +596,15 @@ export class BrowserView extends Disposable { ); } + get owner(): IBrowserViewOwner { + return this._owner; + } + + setOwner(owner: IBrowserViewOwner): void { + this._owner = owner; + this._onDidChangeOwner.fire(owner); + } + get webContents(): Electron.WebContents { return this._view.webContents; } @@ -748,7 +762,7 @@ export class BrowserView extends Disposable { } logBrowserOpen(this.telemetryService, 'browserLinkForeground'); - this._createChildView(url, undefined, { + this._createChildView(this.owner, url, undefined, { pinned: true, parentViewId: this.id }); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index 9808c0edaea10..b235c432f59c5 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -9,7 +9,7 @@ import { BrowserView } from './browserView.js'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IBrowserViewGroup, IBrowserViewGroupFilter, matchesBrowserViewGroupFilter } from '../common/browserViewGroup.js'; -import { IBrowserViewCreationContext } from '../common/browserView.js'; +import { BrowserViewStorageScope, IBrowserViewCreationContext } from '../common/browserView.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { BrowserSession } from './browserSession.js'; @@ -317,11 +317,17 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } async createBrowserContext(): Promise { - const browserSession = BrowserSession.getOrCreateEphemeral(this.instantiationService, generateUuid(), 'cdp-created'); - const contextId = browserSession.id; - this.knownContextIds.add(contextId); - this.ownedContextIds.add(contextId); - return contextId; + const contextId = generateUuid(); + const sessionSelector = this.targetContext.session; + const usesAgentStorage = typeof sessionSelector === 'string' + ? BrowserSession.get(sessionSelector)?.storageScope === BrowserViewStorageScope.Agent + : sessionSelector.scope === BrowserViewStorageScope.Agent; + const browserSession = usesAgentStorage + ? BrowserSession.getOrCreateAgent(this.instantiationService, undefined, contextId) + : BrowserSession.getOrCreateEphemeral(this.instantiationService, contextId, 'cdp-created'); + this.knownContextIds.add(browserSession.id); + this.ownedContextIds.add(browserSession.id); + return browserSession.id; } async disposeBrowserContext(browserContextId: string): Promise { diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 7de2bb5cd3ea0..a21b231300fd9 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -109,7 +109,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa id, selector, this.environmentMainService.workspaceStorageHome, - hostWindow.openedWorkspace?.id + hostWindow.openedWorkspace?.id, + hostWindowId ); } @@ -177,6 +178,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeFavicon; } + onDynamicDidChangeOwner(id: string) { + return this._getBrowserView(id).onDidChangeOwner; + } + onDynamicDidFindInPage(id: string) { return this._getBrowserView(id).onDidFindInPage; } @@ -237,6 +242,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this.browserViews.deleteAndDispose(id); } + async setOwner(id: string, owner: IBrowserViewOwner): Promise { + this._getBrowserView(id).setOwner(owner); + } + async layout(id: string, bounds: IBrowserViewBounds): Promise { return this._getBrowserView(id).layout(bounds); } @@ -440,10 +449,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa associatedResource, browserSession, // Child views share their host, owner, and storage, but do not implicitly inherit agent access. - (url, electronOptions, editorOptions) => { + (childOwner, url, electronOptions, editorOptions) => { return this._createBrowserView(generateUuid(), { hostWindowId, - owner, + owner: childOwner, session: browserSession.id, initialUrl: url || undefined }, editorOptions, electronOptions); diff --git a/src/vs/platform/browserView/node/playwrightChannel.ts b/src/vs/platform/browserView/node/playwrightChannel.ts index f0cd0bc8ea0d9..a22d98bccb398 100644 --- a/src/vs/platform/browserView/node/playwrightChannel.ts +++ b/src/vs/platform/browserView/node/playwrightChannel.ts @@ -58,10 +58,10 @@ export class PlaywrightChannel extends Disposable implements IServerChannel; - return typeof candidate.windowId === 'number'; + return typeof candidate.windowId === 'number' + && typeof candidate.useSessionStorageAffinity === 'boolean'; } diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 135069b93b3fb..25fdd3ef5d322 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -71,6 +71,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService constructor( private readonly windowId: number, + private readonly useSessionStorageAffinity: boolean, private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, private readonly logService: ILogService, private readonly agentNetworkFilterService: IAgentNetworkFilterService, @@ -117,7 +118,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService { audience: { type: 'agent', sessionId } }, { hostWindowId: this.windowId, - ...getAgentBrowserViewCreationDefaults(sessionId) + ...getAgentBrowserViewCreationDefaults(sessionId, this.useSessionStorageAffinity ? sessionId : undefined) } ); diff --git a/src/vs/platform/browserView/test/common/browserView.test.ts b/src/vs/platform/browserView/test/common/browserView.test.ts index ca4d1e0cfec29..ed064c59023f7 100644 --- a/src/vs/platform/browserView/test/common/browserView.test.ts +++ b/src/vs/platform/browserView/test/common/browserView.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience } from '../../common/browserView.js'; +import { BrowserViewStorageScope, getAgentBrowserViewCreationDefaults, isBrowserViewAssociatedResourceNavigation, isInMemoryStorageScope, matchesBrowserViewAudience } from '../../common/browserView.js'; suite('BrowserView', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -54,4 +54,39 @@ suite('BrowserView', () => { otherSession: false }); }); + + test('configures agent storage affinity independently from ownership', () => { + assert.deepStrictEqual({ + editorWindow: getAgentBrowserViewCreationDefaults('chat-session'), + agentsWindow: getAgentBrowserViewCreationDefaults('chat-session', 'chat-session'), + }, { + editorWindow: { + owner: { type: 'agent', sessionId: 'chat-session' }, + initialAudiences: [{ type: 'agent' }], + session: { scope: BrowserViewStorageScope.Agent } + }, + agentsWindow: { + owner: { type: 'agent', sessionId: 'chat-session' }, + initialAudiences: [{ type: 'agent' }], + session: { + scope: BrowserViewStorageScope.Agent, + affinity: 'chat-session' + } + } + }); + }); + + test('identifies in-memory storage scopes', () => { + assert.deepStrictEqual({ + global: isInMemoryStorageScope(BrowserViewStorageScope.Global), + workspace: isInMemoryStorageScope(BrowserViewStorageScope.Workspace), + ephemeral: isInMemoryStorageScope(BrowserViewStorageScope.Ephemeral), + agent: isInMemoryStorageScope(BrowserViewStorageScope.Agent), + }, { + global: false, + workspace: false, + ephemeral: true, + agent: true, + }); + }); }); diff --git a/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts b/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts index 47f07ccb6e99c..e89fd6c5245dc 100644 --- a/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts +++ b/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts @@ -9,6 +9,7 @@ import { EventEmitter } from 'events'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { StorageScope, StorageTarget } from '../../../storage/common/storage.js'; import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js'; +import { BrowserViewStorageScope } from '../../common/browserView.js'; import { BrowserSessionTrust } from '../../electron-main/browserSessionTrust.js'; import type { BrowserSession } from '../../electron-main/browserSession.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -38,6 +39,7 @@ class TestBrowserSession { constructor( readonly id: string, readonly electronSession: Electron.Session, + readonly storageScope: BrowserViewStorageScope, ) { } asBrowserSession(): BrowserSession { @@ -77,13 +79,13 @@ class TestWebContents extends EventEmitter { } } -function createTrust(sessionId = 'test-session'): { +function createTrust(sessionId = 'test-session', storageScope = BrowserViewStorageScope.Global): { trust: BrowserSessionTrust; electronSession: TestElectronSession; storage: TestApplicationStorageMainService; } { const electronSession = new TestElectronSession(); - const browserSession = new TestBrowserSession(sessionId, electronSession.asSession()); + const browserSession = new TestBrowserSession(sessionId, electronSession.asSession(), storageScope); const trust = new BrowserSessionTrust(browserSession.asBrowserSession()); const storage = new TestApplicationStorageMainService(); @@ -239,12 +241,53 @@ suite('BrowserSessionTrust', () => { assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'valid.example.com', fingerprint: 'valid' }]); })); + test('connectStorage ignores in-memory sessions', async () => { + const results = []; + for (const storageScope of [BrowserViewStorageScope.Ephemeral, BrowserViewStorageScope.Agent]) { + const { trust, storage } = createTrust('test-session', storageScope); + const persisted = JSON.stringify({ + 'test-session': { + trustedCerts: [{ host: 'persisted.example.com', fingerprint: 'persisted', expiresAt: Date.now() + 1000 }] + } + }); + storage.seed(STORAGE_KEY, persisted); + + trust.connectStorage(storage.asService()); + await trust.trustCertificate('memory.example.com', 'memory'); + + results.push({ + storageScope, + persistedTrustRestored: trust.isCertificateTrusted('persisted.example.com', 'persisted'), + inMemoryTrustAdded: trust.isCertificateTrusted('memory.example.com', 'memory'), + storageWrites: storage.store.callCount, + storageUnchanged: storage.read(STORAGE_KEY) === persisted, + }); + } + + assert.deepStrictEqual(results, [ + { + storageScope: BrowserViewStorageScope.Ephemeral, + persistedTrustRestored: false, + inMemoryTrustAdded: true, + storageWrites: 0, + storageUnchanged: true, + }, + { + storageScope: BrowserViewStorageScope.Agent, + persistedTrustRestored: false, + inMemoryTrustAdded: true, + storageWrites: 0, + storageUnchanged: true, + }, + ]); + }); + test('stored and reloaded trust expires and is pruned', async () => { const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); const storage = new TestApplicationStorageMainService(); const firstSession = new TestElectronSession(); - const firstBrowserSession = new TestBrowserSession('test-session', firstSession.asSession()); + const firstBrowserSession = new TestBrowserSession('test-session', firstSession.asSession(), BrowserViewStorageScope.Global); const firstTrust = new BrowserSessionTrust(firstBrowserSession.asBrowserSession()); firstTrust.connectStorage(storage.asService()); await firstTrust.trustCertificate('reload.example.com', 'reload-fingerprint'); @@ -252,7 +295,7 @@ suite('BrowserSessionTrust', () => { clock.tick(TRUST_DURATION_MS + 1); const secondSession = new TestElectronSession(); - const secondBrowserSession = new TestBrowserSession('test-session', secondSession.asSession()); + const secondBrowserSession = new TestBrowserSession('test-session', secondSession.asSession(), BrowserViewStorageScope.Global); const secondTrust = new BrowserSessionTrust(secondBrowserSession.asBrowserSession()); const webContents = new TestWebContents(); secondTrust.installCertErrorHandler(webContents.asWebContents()); diff --git a/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts b/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts index 13abd3ce766d1..589f4cbb7d9bd 100644 --- a/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts +++ b/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts @@ -71,7 +71,7 @@ export class InterceptingTelemetryService implements ITelemetryService { this._baseService.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this._baseService.setCommonProperty(name, value); } } diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 784bf55e050c6..e4839aeed658d 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -38,6 +38,8 @@ export interface IManagedSettingsCompatibilityError { export interface IDefaultAccountRefreshOptions { readonly forceRefresh?: boolean; + /** Refreshes entitlement data even when its cache is fresh. */ + readonly refreshEntitlements?: boolean; /** Allows an explicit user action to retry managed settings after a failed attempt. */ readonly retryManagedSettings?: boolean; } diff --git a/src/vs/platform/telemetry/common/editTelemetry.ts b/src/vs/platform/telemetry/common/editTelemetry.ts index 2bb9b4d03bbce..3f96461528abd 100644 --- a/src/vs/platform/telemetry/common/editTelemetry.ts +++ b/src/vs/platform/telemetry/common/editTelemetry.ts @@ -9,6 +9,7 @@ export type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWin export type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; export interface IEditSourcesDetailsTelemetryData { + copilotSku?: string; mode: EditTelemetryMode; sourceKey: string; sourceKeyCleaned: string; @@ -28,6 +29,7 @@ export interface IEditSourcesDetailsTelemetryData { } type EditSourcesDetailsTelemetryClassification = { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; owner: 'hediet'; comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either longterm, 10minFocusWindow, or 20minFocusWindow.' }; @@ -53,6 +55,7 @@ export function sendEditSourcesDetailsTelemetry(telemetryService: ITelemetryServ } export interface IEditSourcesStatsTelemetryData { + copilotSku?: string; attributionSchemaVersion: 2; mode: EditTelemetryMode; languageId?: string; @@ -78,6 +81,7 @@ export interface IEditSourcesStatsTelemetryData { } type EditSourcesStatsTelemetryClassification = { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; owner: 'hediet'; comment: 'Aggregates character counts by edit source category (user typing, AI completions, NES, IDE actions, external changes) for each editing session. Sessions represent units of work and end when documents close, branches change, commits occur, or time limits are reached (10 or 20 minutes of focus time for visible documents, or 10 hours otherwise). Focus time is computed as accumulated 1-minute blocks where VS Code has focus and there was recent user activity. Tracks both total characters inserted and characters remaining at session end to measure retention. This high-level summary complements editSources.details which provides granular per-source breakdowns. @sentToGitHub'; diff --git a/src/vs/platform/telemetry/common/errorTelemetry.ts b/src/vs/platform/telemetry/common/errorTelemetry.ts index 7d5c8deeceab5..cdb522a1ae24a 100644 --- a/src/vs/platform/telemetry/common/errorTelemetry.ts +++ b/src/vs/platform/telemetry/common/errorTelemetry.ts @@ -34,8 +34,13 @@ type ListenerLeakDiagFragment = { listenerCount?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Number of listeners on the emitter when the leak was detected.' }; }; -type UnhandledErrorEvent = ErrorEvent & ListenerLeakDiagEvent; -type UnhandledErrorClassification = ErrorEventFragment & ListenerLeakDiagFragment; +type UnhandledErrorEvent = ErrorEvent & ListenerLeakDiagEvent & { + copilotSku?: string; +}; + +type UnhandledErrorClassification = ErrorEventFragment & ListenerLeakDiagFragment & { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; +}; export interface ErrorEvent { callstack: string; diff --git a/src/vs/platform/telemetry/common/telemetry.ts b/src/vs/platform/telemetry/common/telemetry.ts index e79684b7dd143..29b71ef796915 100644 --- a/src/vs/platform/telemetry/common/telemetry.ts +++ b/src/vs/platform/telemetry/common/telemetry.ts @@ -53,10 +53,10 @@ export interface ITelemetryService { setExperimentProperty(name: string, value: string): void; /** - * Sets a common property that will be attached to all telemetry events. + * Sets a common property that will be attached to all telemetry events, or removes it when the value is undefined. * Common properties are added after PII cleaning and cannot be overridden by event data. */ - setCommonProperty(name: string, value: string | boolean): void; + setCommonProperty(name: string, value: string | boolean | undefined): void; } export function telemetryLevelEnabled(service: ITelemetryService, level: TelemetryLevel): boolean { diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index f3ffc70c898b8..18f60022ff32e 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -151,8 +151,12 @@ export class TelemetryService implements ITelemetryService { } } - setCommonProperty(name: string, value: string | boolean): void { - this._commonProperties[name] = value; + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this._commonProperties[name]; + } else { + this._commonProperties[name] = value; + } } private _flushPendingEvents(): void { diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index 64cca827eb5c0..1447eddaaef6e 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -227,7 +227,7 @@ suite('TelemetryService', () => { service.dispose(); }); - test('setCommonProperty adds property to all subsequent events', function () { + test('setCommonProperty adds and removes property from subsequent events', function () { const testAppender = new TestTelemetryAppender(); const service = new TelemetryService({ appenders: [testAppender], @@ -236,9 +236,14 @@ suite('TelemetryService', () => { service.publicLog('eventBeforeSet'); service.setCommonProperty('common.copilotTrackingId', 'test-tracking-id'); service.publicLog('eventAfterSet'); - - assert.strictEqual(testAppender.events[0].data['common.copilotTrackingId'], undefined); - assert.strictEqual(testAppender.events[1].data['common.copilotTrackingId'], 'test-tracking-id'); + service.setCommonProperty('common.copilotTrackingId', undefined); + service.publicLog('eventAfterClear'); + + assert.deepStrictEqual(testAppender.events.map(event => event.data['common.copilotTrackingId']), [ + undefined, + 'test-tracking-id', + undefined, + ]); service.dispose(); }); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index c4f1dca5e9a57..3d017432d98ee 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -110,12 +110,6 @@ Changes to that item shape must remain aligned across: New fields should be optional unless the proposal explicitly introduces a breaking version. -## Enabling and disabling built-in skills - -Built-in discovery and user enablement are separate stores. Discovery determines which built-in items exist; enablement records the user's disabled set. Item projection combines both and keeps the built-in source distinct from extension and user storage. - -Harness filtering must happen before enablement presentation so an item hidden from a harness cannot be reintroduced by its stored enablement state. - ## Feature gating Customization surfaces are hidden when AI features are disabled. Contributions use `ChatContextKeys.enabled` for declarative visibility and the applicable entitlement state for programmatic hiding. diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index 7af1ca383140f..9a562e6a26ebb 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -147,7 +147,7 @@ .agent-sessions-workbench:not(.dock-detail-panel) .part.auxiliarybar { margin: 0 0 0 0; - padding-left: 5px; + padding-left: var(--vscode-spacing-size60); background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-top-left-radius: 0; @@ -291,8 +291,19 @@ } .agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title { - border-bottom: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); box-sizing: border-box; + position: relative; +} + +.agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title::after { + content: ''; + position: absolute; + right: var(--vscode-spacing-size100); + bottom: 0; + left: var(--vscode-spacing-size100); + height: var(--vscode-strokeThickness); + background: var(--vscode-agentsPanel-border); + pointer-events: none; } .agent-sessions-workbench.dock-detail-panel .part.auxiliarybar > .content .pane-body, diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 87ab0ab041315..cd278d7e84e11 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -67,11 +67,14 @@ export const Menus = { /** * Entries merged into the dropdown of the changes button bar's primary * button. A submenu contributed to its `primary` group names a group of - * related actions and takes over the button when it applies. + * related actions, takes over the button when it applies, and uses its first + * entry as the primary invocation. */ ChangesOperationsDropdown: new MenuId('SessionsChangesOperationsDropdown'), - /** Agent Merge's own entries, opened as a context menu from its button. */ + /** Agent Merge entries whose first visible action is invoked by its primary button. */ ChangesAgentMerge: new MenuId('SessionsChangesAgentMerge'), + /** Per-session Agent Merge configuration. */ + ChangesAgentMergeConfigure: new MenuId('SessionsChangesAgentMergeConfigure'), /** Choices for when Agent Merge may merge the pull request. */ ChangesAgentMergeMergePullRequest: new MenuId('SessionsChangesAgentMergeMergePullRequest'), diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 9a68ffb5b17f1..18d8feeec0442 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -21,6 +21,9 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { Context as SuggestContext } from '../../../../editor/contrib/suggest/browser/suggest.js'; +import { State as SuggestState } from '../../../../editor/contrib/suggest/browser/suggestModel.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ActionListItemKind, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; @@ -72,8 +75,9 @@ export function isAutomationDialogPopupTarget(relatedTarget: HTMLElement): boole ); } -export function isAutomationDialogEditCommand(commandId: string, target: HTMLElement): boolean { - return (commandId === 'undo' || commandId === 'redo') && DOM.isEditableElement(target); +export function shouldPassThroughAutomationDialogCommand(commandId: string, target: HTMLElement): boolean { + return commandId === 'acceptSelectedSuggestion' + || ((commandId === 'undo' || commandId === 'redo') && DOM.isEditableElement(target)); } export async function canSelectAutomationWorkspace( @@ -104,6 +108,7 @@ export function registerAutomationDialogKeyboardNavigation( targetWindow: Window & typeof globalThis, getFocusableElements: () => readonly HTMLElement[], isPopupTarget: (target: HTMLElement) => boolean, + acceptPromptSuggestion: () => boolean = () => false, ): IAutomationDialogKeyboardNavigation { const store = new DisposableStore(); let suppressPopupEscapeKeyUp = false; @@ -134,6 +139,11 @@ export function registerAutomationDialogKeyboardNavigation( if (event.key !== 'Tab') { return; } + if (!event.shiftKey && acceptPromptSuggestion()) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } const focusableElements = visibleFocusableElements(); if (focusableElements.length === 0) { @@ -201,6 +211,7 @@ interface IRenderFormHandle { readonly getBranch: () => string | undefined; readonly waitForAutomationSessionSync: () => Promise; readonly getFocusableElements: () => readonly HTMLElement[]; + readonly acceptPromptSuggestion: () => boolean; } export type AutomationSessionDraftTarget = @@ -1249,6 +1260,14 @@ export function renderForm( // eslint-disable-next-line no-restricted-syntax -- the dialog owns this form subtree and supplies its dynamic focus order. return Array.from(form.querySelectorAll('input, select, textarea, button, a[href], [tabindex]')); }, + acceptPromptSuggestion: () => { + const suggestController = SuggestController.get(chatInput.inputEditor); + if (!suggestController || suggestController.model.state === SuggestState.Idle || !suggestController.widget.value.getFocusedItem()) { + return false; + } + suggestController.acceptSelectedSuggestion(true, false); + return true; + }, }; } @@ -1422,6 +1441,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ when: ContextKeyExpr.and( EditorContextKeys.textInputFocus, ChatContextKeys.inAutomationsDialog, + SuggestContext.Visible.toNegated(), ), primary: KeyCode.Enter, handler: (accessor) => { diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 97b3e28b16ef8..38e8f2c7f16ba 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -26,7 +26,7 @@ import { ILanguageModelsService } from '../../../../workbench/contrib/chat/commo import { IHostService } from '../../../../workbench/services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { IFormState, IValidationState, isAutomationDialogEditCommand, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, updateSaveButtonState } from './automationDialog.js'; +import { IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; const $ = DOM.$; @@ -183,13 +183,14 @@ export class AutomationDialogService implements IAutomationDialogService { ...(cancelButton ? [cancelButton.element] : []), ], isAutomationDialogPopupTarget, + handle.acceptPromptSuggestion, )); focusFirst = keyboardNavigation.focusFirst; revalidate = () => updateSaveButtonState(saveButton, state, validation, form, getPrompt, getBranch); revalidate(); }, }, this.keybindingService, this.layoutService, this.hostService, automationDialogAllowableCommands, - (commandId, event) => isAutomationDialogEditCommand(commandId, event.target)), + (commandId, event) => shouldPassThroughAutomationDialogCommand(commandId, event.target)), )); activeContainer.classList.add('automation-dialog-open'); diff --git a/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts index 16f8d04077a19..59c723edc9d2f 100644 --- a/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts +++ b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts @@ -3,31 +3,64 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; import { CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatInputCompletionItem, IChatSessionsService, isAgentHostTarget } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { AgentHostInputCompletionsBase } from '../../../../workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.js'; +import { registerChatInputReferenceDecorationType } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +const ACCEPT_AUTOMATION_SKILL_COMPLETION_COMMAND = 'sessions.automations.acceptSkillCompletion'; +const AUTOMATION_SKILL_DECORATION_TYPE = 'automation-skill-reference'; + +interface IAcceptAutomationSkillCompletionArgument { + readonly handler: AutomationInputCompletions; + readonly range: Range; + readonly text: string; +} + +CommandsRegistry.registerCommand(ACCEPT_AUTOMATION_SKILL_COMPLETION_COMMAND, (_accessor, argument: IAcceptAutomationSkillCompletionArgument) => { + argument.handler.acceptCompletion(argument.range, argument.text); +}); + export class AutomationInputCompletions extends AgentHostInputCompletionsBase { private readonly registration = this._register(new MutableDisposable()); + private readonly restoreRequest = this._register(new MutableDisposable()); + private readonly restoreScheduler = this._register(new RunOnceScheduler(() => this.restorePersistedSkillReferences(), 200)); + private references: Array<{ decorationId: string; text: string }> = []; + private triggerCharacters: readonly string[] = []; constructor( private readonly editor: ICodeEditor, @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, @IChatSessionsService chatSessionsService: IChatSessionsService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ICodeEditorService codeEditorService: ICodeEditorService, + @ILogService private readonly logService: ILogService, ) { super(languageFeaturesService, chatSessionsService); + this._register(registerChatInputReferenceDecorationType(codeEditorService, AUTOMATION_SKILL_DECORATION_TYPE)); + this._register(this.editor.onDidChangeModelContent(() => { + this.restoreRequest.clear(); + this.updateDecorations(); + this.restoreScheduler.schedule(); + })); + let currentScheme: string | undefined; this._register(autorun(reader => { const session = this.sessionsManagementService.automationSession.read(reader); @@ -37,6 +70,9 @@ export class AutomationInputCompletions extends AgentHostInputCompletionsBase ({ text: match[0], start: match.index, end: match.index + match[0].length })) + .filter(token => this.triggerCharacters.includes(token.text[0])); + if (tokens.length === 0) { + return; + } + + this.restoreRequest.clear(); + const request = new CancellationTokenSource(); + this.restoreRequest.value = toDisposable(() => request.dispose(true)); + void (async () => { + const distinctTokens = [...new Map(tokens.map(token => [token.text, token])).values()]; + const skillResults = await Promise.all(distinctTokens.map(async token => { + const result = await this._chatSessionsService.provideChatInputCompletions(session.resource, { text: value, offset: token.end }, request.token); + return [token.text, result?.items.some(item => + (item.attachment.kind === 'skill' || (item.attachment.kind === 'command' && item.attachment.isSkill)) + && item.insertText.trimEnd() === token.text + ) === true] as const; + })); + if (request.token.isCancellationRequested) { + return; + } + const skillTexts = new Set(skillResults.filter(([, isSkill]) => isSkill).map(([text]) => text)); + const restored = tokens.flatMap(token => + skillTexts.has(token.text) + ? [{ + range: Range.fromPositions(model.getPositionAt(token.start), model.getPositionAt(token.end)), + text: token.text, + }] + : [] + ); + if (session === this.sessionsManagementService.automationSession.get() && model === this.editor.getModel() && model.getValue() === value) { + this.updateDecorations(undefined, restored, true); + } + })().catch(error => { + if (!request.token.isCancellationRequested) { + this.logService.error('[AutomationInputCompletions] Failed to restore persisted skill references', error); + } + }); + } + + private updateDecorations(accepted?: { range: Range; text: string }, restored: readonly { range: Range; text: string }[] = [], replace = false): void { + const model = this.editor.getModel(); + if (!model) { + this.references = []; + return; + } + + const references = replace ? [] : this.references.flatMap(reference => { + const range = model.getDecorationRange(reference.decorationId); + return range && model.getValueInRange(range) === reference.text ? [{ range, text: reference.text }] : []; + }); + if (accepted) { + references.push(accepted); + } + for (const reference of restored) { + if (!references.some(existing => existing.text === reference.text && Range.equalsRange(existing.range, reference.range))) { + references.push(reference); + } + } + const decorationIds = this.editor.setDecorationsByType('chat', AUTOMATION_SKILL_DECORATION_TYPE, references.map(reference => ({ range: reference.range }))); + this.references = decorationIds.map((decorationId, index) => ({ decorationId, text: references[index].text })); + } } diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index cd78db7ee3824..f074e1abaddda 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -15,23 +15,28 @@ import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; +import { Context as SuggestContext } from '../../../../../editor/contrib/suggest/browser/suggest.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js'; import { IListAccessibilityProvider } from '../../../../../base/browser/ui/list/listWidget.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IContext } from '../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ResultKind } from '../../../../../platform/keybinding/common/keybindingResolver.js'; +import { KeybindingsRegistry } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IWorkspaceTrustRequestService, ResourceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { createWorkbenchDialogOptions } from '../../../../../workbench/browser/parts/dialogs/dialog.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { GitRefType, IGitRepository, IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { ISession, ISessionWorkspace, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogEditCommand, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, resolveAutomationModelIdentifier, updateSaveButtonState } from '../../browser/automationDialog.js'; +import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, resolveAutomationModelIdentifier, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; import { AutomationIsolationModel } from '../../common/isolationGroupModel.js'; const FOLDER = URI.file('/workspace'); @@ -51,7 +56,7 @@ function dispatchAutomationDialogCommand(target: HTMLElement, commandId: string) upcastPartial({ activeContainer: document.body }), upcastPartial({}), new Set(), - (id, event) => isAutomationDialogEditCommand(id, event.target), + (id, event) => shouldPassThroughAutomationDialogCommand(id, event.target), ); target.addEventListener('keydown', event => options.keyEventProcessor?.(new StandardKeyboardEvent(event)), { once: true }); return dispatchKey(target, 'keydown', 'z'); @@ -937,23 +942,47 @@ suite('Automation branch picker', () => { suite('Automation dialog keyboard navigation', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('allows undo and redo only for editable controls', () => { + test('passes editor commands through the dialog command filter', () => { const prompt = document.createElement('textarea'); const button = document.createElement('button'); assert.deepStrictEqual({ undoPromptPrevented: dispatchAutomationDialogCommand(prompt, 'undo').defaultPrevented, redoPromptPrevented: dispatchAutomationDialogCommand(prompt, 'redo').defaultPrevented, + acceptSuggestionPromptPrevented: dispatchAutomationDialogCommand(prompt, 'acceptSelectedSuggestion').defaultPrevented, undoButtonPrevented: dispatchAutomationDialogCommand(button, 'undo').defaultPrevented, unrelatedPromptPrevented: dispatchAutomationDialogCommand(prompt, 'workbench.action.files.save').defaultPrevented, }, { undoPromptPrevented: false, redoPromptPrevented: false, + acceptSuggestionPromptPrevented: false, undoButtonPrevented: true, unrelatedPromptPrevented: true, }); }); + test('reserves Enter for suggestions while the suggest widget is visible', () => { + const rule = KeybindingsRegistry.getDefaultKeybindings() + .find(item => item.command === 'workbench.action.chat.automationsDialog.insertNewline'); + const evaluate = (suggestWidgetVisible: boolean) => rule?.when?.evaluate({ + getValue: (key: string) => ({ + [EditorContextKeys.textInputFocus.key]: true, + [ChatContextKeys.inAutomationsDialog.key]: true, + [SuggestContext.Visible.key]: suggestWidgetVisible, + })[key] as T | undefined, + } satisfies IContext) ?? false; + + assert.deepStrictEqual({ + ruleRegistered: !!rule, + withoutSuggestions: evaluate(false), + withSuggestions: evaluate(true), + }, { + ruleRegistered: true, + withoutSuggestions: true, + withSuggestions: false, + }); + }); + test('cycles through visible dialog controls', () => { const container = document.createElement('div'); document.body.append(container); @@ -989,6 +1018,49 @@ suite('Automation dialog keyboard navigation', () => { }); }); + test('accepts a prompt suggestion before moving focus with Tab', () => { + const container = document.createElement('div'); + document.body.append(container); + disposables.add({ dispose: () => container.remove() }); + const targetWindow = DOM.getWindow(container); + const prompt = container.appendChild(document.createElement('textarea')); + const next = container.appendChild(document.createElement('button')); + let acceptedSuggestions = 0; + disposables.add(registerAutomationDialogKeyboardNavigation( + targetWindow, + () => [prompt, next], + () => false, + () => { + acceptedSuggestions++; + return true; + }, + )); + let downstreamKeyDowns = 0; + disposables.add(DOM.addDisposableListener(targetWindow, DOM.EventType.KEY_DOWN, () => downstreamKeyDowns++, true)); + + prompt.focus(); + const shiftTabEvent = dispatchKey(prompt, 'keydown', 'Tab', true); + const activeElementAfterShiftTab = document.activeElement; + prompt.focus(); + const event = dispatchKey(prompt, 'keydown', 'Tab'); + + assert.deepStrictEqual({ + activeElement: document.activeElement, + activeElementAfterShiftTab, + acceptedSuggestions, + defaultPrevented: event.defaultPrevented, + downstreamKeyDowns, + shiftTabDefaultPrevented: shiftTabEvent.defaultPrevented, + }, { + activeElement: prompt, + activeElementAfterShiftTab: next, + acceptedSuggestions: 1, + defaultPrevented: true, + downstreamKeyDowns: 0, + shiftTabDefaultPrevented: true, + }); + }); + test('leaves popup keydown handling active and suppresses its Escape keyup', () => { const container = document.createElement('div'); document.body.append(container); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts index 0045511fbf78a..a838d1d398bd6 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts @@ -4,17 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Position } from '../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../editor/common/core/range.js'; import { CompletionItemKind, CompletionTriggerKind } from '../../../../../editor/common/languages.js'; import { LanguageFeaturesService } from '../../../../../editor/common/services/languageFeaturesService.js'; +import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js'; import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IChatInputCompletionsParams, IChatInputCompletionsResult, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -50,10 +58,23 @@ class TestChatSessionsService extends mock() { suite('AutomationInputCompletions', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => sinon.restore()); + test('shows agent host skills for the automation draft session', async () => { const languageFeaturesService = new LanguageFeaturesService(); const model = store.add(createTextModel('/', null, undefined, URI.parse('vscode-chat-input:automation'))); - const editor = upcastPartial({ getModel: () => model }); + let decorations: readonly Range[] = []; + const editor = upcastPartial({ + getModel: () => model, + onDidChangeModelContent: Event.None, + setDecorationsByType: (_description, _key, options) => { + decorations = options.map(option => Range.lift(option.range)); + return options.map((_, index) => `decoration-${index}`); + }, + }); + const codeEditorService = upcastPartial({ + registerDecorationType: () => ({ dispose() { } }), + }); const session = upcastPartial({ sessionId: 'automation', resource: URI.parse('agent-host-copilot:automation'), @@ -61,7 +82,7 @@ suite('AutomationInputCompletions', () => { const sessionsManagementService = upcastPartial({ automationSession: constObservable(session), }); - store.add(new AutomationInputCompletions(editor, languageFeaturesService, new TestChatSessionsService(), sessionsManagementService)); + store.add(new AutomationInputCompletions(editor, languageFeaturesService, new TestChatSessionsService(), sessionsManagementService, codeEditorService, new NullLogService())); await timeout(0); const provider = languageFeaturesService.completionProvider.ordered(model)[0]; @@ -72,27 +93,88 @@ suite('AutomationInputCompletions', () => { CancellationToken.None, ); - assert.deepStrictEqual(result?.suggestions.map(item => ({ + const suggestions = result?.suggestions.map(item => ({ label: item.label, insertText: item.insertText, filterText: item.filterText, documentation: item.documentation, kind: item.kind, - })), [ - { - label: { label: '/review ', description: 'Review the workspace' }, - insertText: '/review ', - filterText: '/review ', - documentation: 'Review the workspace', - kind: CompletionItemKind.Text, - }, - { - label: { label: '/runtime-skill ', description: 'Run a runtime skill' }, - insertText: '/runtime-skill ', - filterText: '/runtime-skill ', - documentation: 'Run a runtime skill', - kind: CompletionItemKind.Text, + })); + model.setValue('/review '); + const command = result?.suggestions[0].command; + CommandsRegistry.getCommand(command!.id)!.handler(upcastPartial({}), ...command!.arguments!); + + assert.deepStrictEqual({ suggestions, decorations }, { + suggestions: [ + { + label: { label: '/review ', description: 'Review the workspace' }, + insertText: '/review ', + filterText: '/review ', + documentation: 'Review the workspace', + kind: CompletionItemKind.Text, + }, + { + label: { label: '/runtime-skill ', description: 'Run a runtime skill' }, + insertText: '/runtime-skill ', + filterText: '/runtime-skill ', + documentation: 'Run a runtime skill', + kind: CompletionItemKind.Text, + }, + ], + decorations: [new Range(1, 1, 1, 8)], + }); + }); + + test('restores persisted skill references and removes stale decorations after edits', async () => { + const languageFeaturesService = new LanguageFeaturesService(); + const model = store.add(createTextModel( + '/review then /plan and /runtime-skill plus /unknown', + null, + undefined, + URI.parse('vscode-chat-input:automation'), + )); + let decorations: readonly Range[] = []; + const decorationRanges = new Map(); + const onDidChangeModelContent = store.add(new Emitter()); + sinon.stub(model, 'getDecorationRange').callsFake(decorationId => decorationRanges.get(decorationId) ?? null); + const editor = upcastPartial({ + getModel: () => model, + onDidChangeModelContent: onDidChangeModelContent.event, + setDecorationsByType: (_description, _key, options) => { + decorations = options.map(option => Range.lift(option.range)); + decorationRanges.clear(); + return decorations.map((range, index) => { + const id = `decoration-${index}`; + decorationRanges.set(id, range); + return id; + }); }, - ]); + }); + const codeEditorService = upcastPartial({ + registerDecorationType: () => ({ dispose() { } }), + }); + const session = upcastPartial({ + sessionId: 'automation', + resource: URI.parse('agent-host-copilot:automation'), + }); + const sessionsManagementService = upcastPartial({ + automationSession: constObservable(session), + }); + store.add(new AutomationInputCompletions(editor, languageFeaturesService, new TestChatSessionsService(), sessionsManagementService, codeEditorService, new NullLogService())); + await timeout(0); + model.setValue('/reviewx then /plan and /runtime-skill plus /unknown'); + decorationRanges.set('decoration-1', new Range(1, 25, 1, 39)); + onDidChangeModelContent.fire(upcastPartial({})); + await timeout(250); + const afterRightEdgeEdit = decorations; + model.setValue('/reviewx then /plan and x/runtime-skill plus /unknown'); + decorationRanges.set('decoration-0', new Range(1, 26, 1, 40)); + onDidChangeModelContent.fire(upcastPartial({})); + await timeout(250); + + assert.deepStrictEqual({ afterRightEdgeEdit, afterLeftEdgeEdit: decorations }, { + afterRightEdgeEdit: [new Range(1, 25, 1, 39)], + afterLeftEdgeEdit: [], + }); }); }); diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index a369dc3fa420b..e2ad345407b13 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -10,6 +10,7 @@ import { structuralEquals } from '../../../../base/common/equals.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; @@ -28,11 +29,11 @@ import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/d import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; -import { AGENT_HOST_PULL_REQUEST_OPERATION_IDS } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasOpenPullRequestContext, SessionHasWorkspaceContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; +import { SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../services/sessions/common/session.js'; import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IChangesViewService } from '../common/changesViewService.js'; @@ -149,6 +150,20 @@ function getChangesDiffEditor(pane: IEditorPane | undefined, resource: URI): Dif return codeEditor?.diffEditor instanceof DiffEditorWidget ? codeEditor.diffEditor : undefined; } +function getExpandedChangesDiffEditor(pane: IEditorPane | undefined, resource: URI): DiffEditorWidget | undefined { + if (pane instanceof SessionChangesEditor) { + pane.expand(resource); + } else if (pane instanceof MultiDiffEditor) { + const viewModel = pane.viewModel; + const item = viewModel?.items.read(undefined) + .find(item => isEqual(item.modifiedUri, resource) || isEqual(item.originalUri, resource)); + if (viewModel && item) { + viewModel.expand(item); + } + } + return getChangesDiffEditor(pane, resource); +} + /** * Reveals all hidden unchanged regions for the file shown in a diff row of the * Agents window's Changes editor, showing the whole file at once (a per-file @@ -181,7 +196,7 @@ class ExpandFullFileAction extends Action2 { return; } - getChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.showAllUnchangedRegions(); + getExpandedChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.showAllUnchangedRegions(); } } registerAction2(ExpandFullFileAction); @@ -222,7 +237,7 @@ class CollapseUnchangedRegionsAction extends Action2 { return; } - getChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.collapseAllUnchangedRegions(); + getExpandedChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.collapseAllUnchangedRegions(); } } registerAction2(CollapseUnchangedRegionsAction); @@ -514,7 +529,64 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme } } +export class NewSessionUncommittedChangesetOperationsActionContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.sessions.newSessionUncommittedChangesetOperationsAction'; + + constructor( + @ISessionsService sessionsService: ISessionsService, + ) { + super(); + + this._register(autorun(reader => { + const activeSession = sessionsService.activeSession.read(reader); + if (activeSession?.status.read(reader) !== SessionStatus.Untitled) { + return; + } + + const changeset = activeSession.changesets.read(reader) + ?.find(candidate => candidate.id === UNCOMMITTED_CHANGES_CHANGESET_ID && candidate.isEnabled.read(reader)); + const operations = changeset?.operations.read(reader) + .filter(operation => operation.id !== AGENT_HOST_SYNC_CHANGESET_OPERATION_ID) + .filter(operation => operation.scopes.includes(SessionChangesetOperationScope.Changeset)) ?? []; + const hasUncommittedChanges = (activeSession.workspace.read(reader)?.folders[0]?.gitRepository?.uncommittedChanges ?? 0) > 0; + + for (let index = 0; index < operations.length; index++) { + const operation = operations[index]; + const precondition = operation.status === SessionChangesetOperationStatus.Disabled + || operation.status === SessionChangesetOperationStatus.Running + || (operation.id === AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID && !hasUncommittedChanges) + ? ContextKeyExpr.false() + : undefined; + + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.contrib.sessions.newSessionUncommittedChangesetOperation.${operation.id}`, + title: operation.label, + tooltip: operation.description, + icon: operation.icon, + precondition, + f1: false, + menu: { + id: Menus.SessionsEditorHeaderLayout, + group: 'navigation', + order: index, + when: ActiveEditorContext.isEqualTo(SessionChangesEditor.ID), + } + }); + } + + async run(): Promise { + await changeset?.invokeOperation(operation.id); + } + })); + } + })); + } +} + registerWorkbenchContribution2(ChangesMultiDiffSourceResolverContribution.ID, ChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChangesetOperationsActionControllerContribution.ID, ChangesetOperationsActionControllerContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(NewSessionUncommittedChangesetOperationsActionContribution.ID, NewSessionUncommittedChangesetOperationsActionContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ViewAllChangesActionViewItemContribution.ID, ViewAllChangesActionViewItemContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionChangesStatsCacheContribution.ID, SessionChangesStatsCacheContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 138b6c155e0c0..072b01cfbf5b5 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -112,6 +112,7 @@ const CHAT_PET_CREATE_PULL_REQUEST_ACTION_IDS = new Set([ 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', + 'create-pr-agent-merge', 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', 'workbench.action.agentSessions.runSkill.createPR', ]); @@ -311,7 +312,6 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @IChatPetService chatPetService: IChatPetService, - @IContextMenuService contextMenuService: IContextMenuService, @ILogService private readonly logService: ILogService, ) { super(); @@ -323,6 +323,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt // config provider below, which `buttonBar.update` calls synchronously // from the same autorun that computes it. let primaryIsBusy = false; + let primaryCustomLabel: string | undefined; const buttonBar = this._buttonBar = this._register(instantiationService.createInstance( WorkbenchButtonBar, @@ -332,7 +333,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt renderSecondaryActions: false, buttonConfigProvider: (action, index) => { return index === 0 - ? { showIcon: true, showLabel: true, customLabel: stripIcons(action.label), showSpinner: primaryIsBusy } + ? { showIcon: true, showLabel: true, customLabel: primaryCustomLabel ?? stripIcons(action.label), showSpinner: primaryIsBusy } : { showIcon: true, showLabel: false }; } } @@ -352,29 +353,28 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt // there takes over the primary button when it applies, which is how // Agent Merge can own the button without the widget knowing about it. // - // A submenu contributed to that group names a group of related actions - // rather than being an action itself, so clicking the button opens just - // those actions as a context menu — the button's own dropdown carries - // unrelated operations too. + // A submenu contributed to that group names related actions. Its first + // entry is the primary invocation; the button's dropdown carries the + // remaining entries together with unrelated operations. const dropdownMenuActionsObs = observableFromEvent(dropdownMenu.onDidChange, () => { const groups = dropdownMenu.getActions({ shouldForwardArgs: true }); const primaryGroup = groups.find(([group]) => group === CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP)?.[1] ?? []; const rest = groups.filter(([group]) => group !== CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP).map(([, actions]) => actions); const contributed = primaryGroup[0]; - const primary = contributed instanceof SubmenuItemAction + const delegated = contributed instanceof SubmenuItemAction ? contributed.actions[0] : undefined; + const primary = contributed instanceof SubmenuItemAction && delegated ? toAction({ - id: contributed.item.submenu.id, - label: contributed.label, + id: delegated.id, + label: delegated.label, + tooltip: delegated.tooltip, + enabled: delegated.enabled, // Wrapping the submenu in a plain action would drop the icon // its menu item declared, so it is carried over the way any // action carries one. class: ThemeIcon.isThemeIcon(contributed.item.icon) ? ThemeIcon.asClassName(contributed.item.icon) : undefined, - run: () => contextMenuService.showContextMenu({ - getAnchor: () => buttonBar.buttons[0]?.element ?? container, - getActions: () => contributed.actions, - }), + run: () => delegated.run(), }) - : contributed; + : contributed instanceof SubmenuItemAction ? undefined : contributed; return { primary, contributed, isAgentMerge: contributed instanceof SubmenuItemAction && contributed.item.submenu === Menus.ChangesAgentMerge, groups: primaryGroup.length > 0 ? [primaryGroup, ...rest] : rest }; }); @@ -494,6 +494,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt primaryIsBusy = usesContributedPrimary ? dropdownMenuActions.isAgentMerge && agentMergeEnabledObs.read(reader) : operations.hasRunning; + primaryCustomLabel = usesContributedPrimary ? stripIcons(dropdownMenuActions.contributed?.label ?? primaryAction?.label ?? '') : undefined; buttonBar.update(primaryActions, menuActions.secondary); this._logButtonBar(primaryAction, usesContributedPrimary, operations.hasRunning, primaryIsBusy, groups, menuActions.primary); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index d32d1ef33b99f..addcd03f330ed 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -198,7 +198,6 @@ class ChangesHeaderActionsAction extends Action2 { registerAction2(ChangesHeaderActionsAction); - class SetChangesListViewModeAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.setChangesListViewMode'; @@ -209,8 +208,8 @@ class SetChangesListViewModeAction extends Action2 { icon: Codicon.listFlat, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '2_viewMode', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/2_viewMode', order: 20, when: ContextKeyExpr.and( singlePaneDiffEditorTitle, @@ -238,8 +237,8 @@ class SetChangesTreeViewModeAction extends Action2 { icon: Codicon.listTree, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '2_viewMode', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/2_viewMode', order: 20, when: ContextKeyExpr.and( singlePaneDiffEditorTitle, diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 64f832e65b3e8..3ed3018713496 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -379,11 +379,19 @@ export class ChangesViewService extends Disposable implements IChangesViewServic // Pull request state const gitHubInfo = gitRepository?.gitHubInfo.read(reader); const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; - const hasOpenPullRequest = hasPullRequest && - (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || - gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id || - gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestError.id || - gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestComment.id); + const hostPullRequestState = gitHubInfo?.pullRequest?.state; + const livePullRequestState = gitHubInfo?.pullRequest?.liveState; + const hasTerminalPullRequestState = hostPullRequestState === 'closed' + || hostPullRequestState === 'merged' + || livePullRequestState === 'closed' + || livePullRequestState === 'merged'; + const hasOpenPullRequest = hasPullRequest && !hasTerminalPullRequestState + && (hostPullRequestState === 'open' + || livePullRequestState === 'open' + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestError.id + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestComment.id); // Repository state const hasGitHubRemote = gitRepository?.hasGitHubRemote ?? false; diff --git a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css b/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css index 91515889bd4d5..e546345b978c8 100644 --- a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css +++ b/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css @@ -7,37 +7,66 @@ * used by the Changes view / editor). Kept out of the shared workbench style so * these overrides live next to the Changes contribution that owns them. */ -/* The per-file diff rows intentionally render flush with the panel (the old - * `padding: 0 8px` inset was removed as part of the spacing rework); only - * `box-sizing` is kept here. */ -.agent-sessions-workbench .part.editor .multiDiffEntry { - box-sizing: border-box; +.agent-sessions-workbench .part.editor .multiDiffEntry::after { + content: ''; + position: absolute; + right: var(--vscode-spacing-size100); + bottom: 0; + left: var(--vscode-spacing-size100); + height: var(--vscode-strokeThickness); + background: var(--vscode-panel-border); + z-index: 1001; + pointer-events: none; } .agent-sessions-workbench .part.editor .multiDiffEntry .header { cursor: pointer; - background: transparent; + background: var(--vscode-multiDiffEditor-background); position: relative; } -.agent-sessions-workbench .part.editor .multiDiffEntry .header::before { +.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered, +.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active { + overflow: visible; + z-index: 1002; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::after, +.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::after, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::after { + right: 0; + left: 0; + z-index: 1004; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::before, +.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::before, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::before { content: ''; position: absolute; - inset: 0; - background: var(--vscode-agentsPanel-background); - z-index: -1; + top: calc(-1 * var(--vscode-strokeThickness)); + right: 0; + left: 0; + height: var(--vscode-strokeThickness); + background: var(--vscode-panel-border); + z-index: 1004; pointer-events: none; } -/* Include the multi-diff owner to outrank the equally specific core - * `.monaco-component.multiDiffEditor .multiDiffEntry .header .header-content` - * rule for the Agents header margin, padding, border, and background. */ -.agent-sessions-workbench .part.editor .multiDiffEditor .multiDiffEntry .header-content { - border-radius: var(--vscode-cornerRadius-medium); +.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-hovered::before, +.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-focused::before, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.first-diff-entry.active::before { + top: 0; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header .header-content { + height: var(--vscode-spacing-size320); + box-sizing: border-box; border: none; - background: var(--vscode-sideBarSectionHeader-background); + background: transparent; margin: 0; - padding-left: var(--vscode-spacing-sizeNone); + padding: var(--vscode-spacing-sizeNone); align-items: center; } @@ -45,9 +74,28 @@ border-bottom: none; } -/* The same owner keeps the Agents body font size from tying the core - * `.header-content .file-path .title` 14px rule. */ -.agent-sessions-workbench .part.editor .multiDiffEditor .multiDiffEntry .header-content .file-path .title { +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header .header-content { + background: var(--vscode-list-inactiveSelectionBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content { + background: var(--vscode-list-hoverBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header:active .header-content { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:hover .header-content, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:active .header-content { + background: var(--vscode-list-inactiveSelectionBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header[aria-expanded="false"] + .editorParent { + border: none; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title { font-size: var(--vscode-fontSize-body1); line-height: 22px; } @@ -85,22 +133,56 @@ gap: 4px; } -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus, -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible { +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) { + visibility: hidden; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry:hover .header-content .actions-container .action-item:not(.changeset-review-action), +.agent-sessions-workbench .part.editor .multiDiffEntry:focus-within .header-content .actions-container .action-item:not(.changeset-review-action) { + visibility: visible; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus { outline: none; } -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus .header-content, .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible .header-content { - box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); } .agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a { - border-radius: var(--vscode-cornerRadius-small); + display: flex; + align-items: center; + justify-content: center; + padding: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-medium); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) .action-label { + padding: var(--vscode-spacing-size40); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:active { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus:not(:focus-visible) { + outline: none; } .agent-sessions-workbench .part.editor .multiDiffEntry .editorParent { - border-bottom: none; + border: none; + overflow: hidden; } .agent-sessions-workbench .part.editor .diff-hidden-lines .center { @@ -113,7 +195,7 @@ * line-number gutter width (which pushes it to the right). */ .agent-sessions-workbench .part.editor .diff-hidden-lines .center > div:first-child { justify-content: flex-start !important; - padding-left: 5px; + padding-left: var(--vscode-spacing-size60); box-sizing: border-box; } diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 214f63182c7b6..6e924a37405f4 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -67,9 +67,16 @@ const CHANGES_DIFF_EDITOR_OPTIONS: IDiffEditorOptions = { lineNumbersMinChars: 3, }; +const CHANGES_LIST_BOTTOM_PADDING_PX = 24; +const CHANGES_ENTRY_HEADER_HEIGHT_PX = 32; +const CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX = 8; + class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { readonly headerClickToCollapse = true; + readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; + readonly diffEditorItemHeaderHeight = CHANGES_ENTRY_HEADER_HEIGHT_PX; + readonly diffEditorItemContentBottomPadding = CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX; constructor( private readonly changesObs: IObservable, @@ -261,6 +268,7 @@ export class SessionChangesEditor extends AbstractEditorWithViewState { this.widget?.setRenderSideBySide(this.diffEditorOptionsService.renderSideBySide.read(reader), { useInlineViewWhenSpaceIsLimited: true }); })); diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts index 8dfb61ea1ce61..0edea0d059055 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -34,7 +34,7 @@ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplemen content.push(localize('sessionsChanges.tree', "Use the up and down arrow keys to move between changed files, and the left and right arrow keys to collapse or expand folders. Press Enter to open the selected file's diff.")); content.push(localize('sessionsChanges.checks', "The Checks section lists the continuous integration checks for the session's pull request. Its header is a button: press Enter or Space to collapse or expand it{0}.", '')); content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); - content.push(localize('sessionsChanges.operations', "When available, the toolbar also provides actions to commit, merge, sync, or create a pull request. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); + content.push(localize('sessionsChanges.operations', "When available, the Changes toolbar or editor title bar also provides actions to commit, merge, sync, or create a pull request. When Agent Merge is the primary action, activate it to toggle Agent Merge and use its dropdown to configure it. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); content.push(layoutService.isSinglePaneLayoutEnabled ? localize('sessionsChanges.diffView.singlePane', "File diffs can prefer side-by-side or inline layout. Unless screen reader optimized mode is enabled, side-by-side diffs automatically use inline layout when space is limited. Use Always Show Inline Diff in the editor title bar's More Actions menu, or use the Toggle Preferred Diff View command to switch the preference{0}.", '') : localize('sessionsChanges.diffView.classic', "File diffs can use side-by-side or inline layout. Use Inline View in the editor title area's More Actions menu, or use the Toggle Inline View command to switch the layout{0}.", '')); diff --git a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts index 6b7dc2250addc..de137ae57ed0a 100644 --- a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts +++ b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts @@ -101,6 +101,11 @@ class FixtureAgentFeedbackMenuService implements IMenuService { class AgentsDiffUIElementFactory implements IWorkbenchUIElementFactory { + readonly headerClickToCollapse = true; + readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; + readonly diffEditorItemHeaderHeight = 32; + readonly diffEditorItemContentBottomPadding = 8; + constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } diff --git a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts index 1a1426306598e..e1d4394b0c4ca 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts @@ -4,16 +4,28 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { Context } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ActiveEditorContext } from '../../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionChangeset, ISessionChangesetOperation, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../common/changes.js'; -import '../../browser/changesActions.js'; +import { NewSessionUncommittedChangesetOperationsActionContribution } from '../../browser/changesActions.js'; +import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; suite('Changes Actions', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('changes pill stays out of the pill row for a session without a workspace folder', () => { const item = MenuRegistry.getMenuItems(Menus.SessionHeaderMeta) @@ -43,4 +55,120 @@ suite('Changes Actions', () => { workspaceSessionWithoutChanges: false, }); }); + + test('draft session contributes uncommitted changeset operations to the editor header', async () => { + const invokedOperations: string[] = []; + const operations = observableValue('test.operations', [{ + id: AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, + label: 'Commit', + description: 'Commit uncommitted changes', + icon: Codicon.check, + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }, { + id: 'discard-file', + label: 'Discard File', + scopes: [SessionChangesetOperationScope.Resource], + status: SessionChangesetOperationStatus.Idle, + }, { + id: AGENT_HOST_SYNC_CHANGESET_OPERATION_ID, + label: 'Sync Changes', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Disabled, + }]); + const changeset = upcastPartial({ + id: UNCOMMITTED_CHANGES_CHANGESET_ID, + label: 'Uncommitted Changes', + isEnabled: constObservable(true), + operations, + invokeOperation: async operationId => { + invokedOperations.push(operationId); + }, + }); + const status = observableValue('test.status', SessionStatus.Untitled); + const workspace = observableValue('test.workspace', upcastPartial({ + folders: [upcastPartial({ + gitRepository: upcastPartial({ + uncommittedChanges: 0, + }), + })], + })); + const activeSession = observableValue('test.activeSession', upcastPartial({ + resource: URI.parse('test-session:draft'), + status, + workspace, + changesets: constObservable([changeset]), + })); + const sessionsService = new class extends mock() { + override readonly activeSession = activeSession; + }(); + disposables.add(new NewSessionUncommittedChangesetOperationsActionContribution(sessionsService)); + + const actionPrefix = 'workbench.contrib.sessions.newSessionUncommittedChangesetOperation.'; + const getActions = () => MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderLayout) + .filter(isIMenuItem) + .filter(item => item.command.id.startsWith(actionPrefix)); + const disabledActions = getActions(); + const disabledCommitAction = disabledActions.find(item => item.command.id === `${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`)!; + const disabledWithoutChanges = disabledCommitAction.command.precondition?.serialize(); + workspace.set(upcastPartial({ + folders: [upcastPartial({ + gitRepository: upcastPartial({ + uncommittedChanges: 1, + }), + })], + }), undefined); + const actions = getActions(); + const commitAction = actions.find(item => item.command.id === `${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`)!; + const context = new Context(1, null); + context.setValue(ActiveEditorContext.key, SessionChangesEditor.ID); + const visibleForChangesTab = commitAction.when?.evaluate(context) ?? true; + context.setValue(ActiveEditorContext.key, 'workbench.editors.textEditor'); + const visibleForTextTab = commitAction.when?.evaluate(context) ?? true; + const instantiationService = disposables.add(new TestInstantiationService()); + await instantiationService.invokeFunction(CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`)!.handler); + + assert.deepStrictEqual({ + actions: actions.map(item => ({ + id: item.command.id, + title: item.command.title, + tooltip: item.command.tooltip, + icon: item.command.icon, + group: item.group, + order: item.order, + precondition: item.command.precondition?.serialize(), + })), + invokedOperations, + disabledWithoutChanges, + visibleForChangesTab, + visibleForTextTab, + resourceOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}discard-file`) !== undefined, + syncOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_SYNC_CHANGESET_OPERATION_ID}`) !== undefined, + }, { + actions: [{ + id: `${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`, + title: 'Commit', + tooltip: 'Commit uncommitted changes', + icon: Codicon.check, + group: 'navigation', + order: 0, + precondition: undefined, + }], + invokedOperations: [AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID], + disabledWithoutChanges: 'false', + visibleForChangesTab: true, + visibleForTextTab: false, + resourceOperationRegistered: false, + syncOperationRegistered: false, + }); + + status.set(SessionStatus.Completed, undefined); + assert.deepStrictEqual({ + menuActions: getActions().length, + commitCommandRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`) !== undefined, + }, { + menuActions: 0, + commitCommandRegistered: false, + }); + }); }); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index e76ca3cccdbaf..8687a09cd6ca7 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -95,6 +95,7 @@ suite('Changes View Actions', () => { 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', + 'create-pr-agent-merge', 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', 'workbench.action.agentSessions.runSkill.createPR', 'create-draft-pr', @@ -103,8 +104,8 @@ suite('Changes View Actions', () => { ].map(actionId => unlockChatPetCreatePullRequestAchievement(actionId, chatPetService)); assert.deepStrictEqual({ results, attemptedUnlocks }, { - results: [true, true, true, true, true, true, false, false, false], - attemptedUnlocks: Array(6).fill(ChatPetAchievementIds.CreatePullRequest), + results: [true, true, true, true, true, true, true, false, false, false], + attemptedUnlocks: Array(7).fill(ChatPetAchievementIds.CreatePullRequest), }); }); @@ -292,45 +293,45 @@ suite('Changes View Actions', () => { assert.strictEqual(getChangesAccessibilityHelp(false).includes('Use Inline View in the editor title area\'s More Actions menu'), true); }); - test('view mode toggles are contributed to the editor title bar overflow for non-text single-file diffs', () => { - const items = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + test('view mode toggles are moved to the editor header layout overflow for non-text single-file diffs', () => { + const getItems = (menuId: MenuId) => MenuRegistry.getMenuItems(menuId) .filter(isIMenuItem) - .filter(item => item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' || item.command.id === 'workbench.action.agentSessions.setChangesTreeViewMode'); + .filter(item => item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' || item.command.id === 'workbench.action.agentSessions.setChangesTreeViewMode') + .map(item => { + const when = item.when?.serialize() ?? ''; + const context = new Context(1, null); + context.setValue(IsSessionsWindowContext.key, true); + context.setValue(SinglePaneDiffEditorInputActiveContext.key, true); + context.setValue(SinglePaneLayoutEnabledContext.key, true); + context.setValue(IsAuxiliaryWindowContext.key, false); + context.setValue(IsTopRightEditorGroupContext.key, true); + context.setValue(AuxiliaryBarVisibleContext.key, true); + context.setValue( + ChangesContextKeys.ViewMode.key, + item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' ? ChangesViewMode.Tree : ChangesViewMode.List + ); + return { + id: item.command.id, + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), + hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), + hasDiffEditorInputGate: when.includes(SinglePaneDiffEditorInputActiveContext.key), + hasSinglePaneConfigGate: when.includes(SinglePaneLayoutEnabledContext.key), + hasAuxBarVisibleGate: when.includes(AuxiliaryBarVisibleContext.key), + hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + hasViewModeGate: when.includes(ChangesContextKeys.ViewMode.key), + matchesSingleFileDiffContext: item.when?.evaluate(context) ?? false, + }; + }) + .sort((a, b) => a.id.localeCompare(b.id)); - const actual = items.map(item => { - const when = item.when?.serialize() ?? ''; - const context = new Context(1, null); - context.setValue(IsSessionsWindowContext.key, true); - context.setValue(SinglePaneDiffEditorInputActiveContext.key, true); - context.setValue(SinglePaneLayoutEnabledContext.key, true); - context.setValue(IsAuxiliaryWindowContext.key, false); - context.setValue(IsTopRightEditorGroupContext.key, true); - context.setValue(AuxiliaryBarVisibleContext.key, true); - context.setValue( - ChangesContextKeys.ViewMode.key, - item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' ? ChangesViewMode.Tree : ChangesViewMode.List - ); - return { - id: item.command.id, - title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, - group: item.group, - order: item.order, - icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, - hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), - hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), - hasDiffEditorInputGate: when.includes(SinglePaneDiffEditorInputActiveContext.key), - hasSinglePaneConfigGate: when.includes(SinglePaneLayoutEnabledContext.key), - hasAuxBarVisibleGate: when.includes(AuxiliaryBarVisibleContext.key), - hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), - hasViewModeGate: when.includes(ChangesContextKeys.ViewMode.key), - matchesSingleFileDiffContext: item.when?.evaluate(context) ?? false, - }; - }).sort((a, b) => a.id.localeCompare(b.id)); - - assert.deepStrictEqual(actual, [{ + const expectedItems = (group: string) => [{ id: 'workbench.action.agentSessions.setChangesListViewMode', title: 'View as List', - group: '2_viewMode', + group, order: 20, icon: Codicon.listFlat.id, hasSessionsWindowGate: true, @@ -344,7 +345,7 @@ suite('Changes View Actions', () => { }, { id: 'workbench.action.agentSessions.setChangesTreeViewMode', title: 'View as Tree', - group: '2_viewMode', + group, order: 20, icon: Codicon.listTree.id, hasSessionsWindowGate: true, @@ -355,7 +356,15 @@ suite('Changes View Actions', () => { hasEditorAreaVisibleGate: false, hasViewModeGate: true, matchesSingleFileDiffContext: true, - }]); + }]; + + assert.deepStrictEqual({ + headerLayout: getItems(Menus.SessionsEditorHeaderLayout), + editorTitleOverflow: getItems(Menus.SessionsEditorTitle), + }, { + headerLayout: expectedItems('secondary/2_viewMode'), + editorTitleOverflow: [], + }); }); test('Create Pull Request anchor is visible for created sessions but hidden for custom views', () => { diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts index 8ecd4e026c284..926bfc3f33151 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -23,8 +24,8 @@ suite('ChangesViewService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createSession(id: string, options?: { readonly changesets?: readonly ISessionChangeset[]; readonly baseBranchProtected?: boolean }): IActiveSession { - const workspace = options?.baseBranchProtected === undefined + function createSession(id: string, options?: { readonly changesets?: readonly ISessionChangeset[]; readonly baseBranchProtected?: boolean; readonly pullRequestState?: 'open' | 'closed' | 'merged'; readonly livePullRequestState?: 'open' | 'closed' | 'merged'; readonly pullRequestIcon?: { readonly id: string } }): IActiveSession { + const workspace = options?.baseBranchProtected === undefined && options?.pullRequestState === undefined && options?.livePullRequestState === undefined && options?.pullRequestIcon === undefined ? undefined : upcastPartial({ folders: [upcastPartial({ @@ -35,7 +36,17 @@ suite('ChangesViewService', () => { workTreeUri: URI.file('/repo.worktrees/session'), baseBranchName: 'main', baseBranchProtected: options.baseBranchProtected, - gitHubInfo: constObservable(undefined), + gitHubInfo: constObservable(options.pullRequestState || options.livePullRequestState || options.pullRequestIcon ? { + owner: 'microsoft', + repo: 'vscode', + pullRequest: { + number: 1, + uri: URI.parse('https://github.com/microsoft/vscode/pull/1'), + icon: options.pullRequestIcon ?? Codicon.gitPullRequest, + state: options.pullRequestState, + liveState: options.livePullRequestState, + }, + } : undefined), }), })], }); @@ -44,6 +55,7 @@ suite('ChangesViewService', () => { providerId: 'local-agent-host', sessionType: 'test', loading: constObservable(false), + changes: constObservable([]), changesets: constObservable(options?.changesets ?? []), workspace: constObservable(workspace), }); @@ -337,4 +349,22 @@ suite('ChangesViewService', () => { ['merge', 'create-pr'], ]); }); + + test('reconciles host pull request state with the live icon', () => { + const openSession = createSession('open', { pullRequestState: 'open' }); + const mergedSession = createSession('merged', { pullRequestState: 'merged', livePullRequestState: 'open' }); + const cachedTerminalSession = createSession('cached-terminal', { pullRequestState: 'open', pullRequestIcon: Codicon.gitPullRequestDone }); + const liveTerminalSession = createSession('live-terminal', { pullRequestState: 'open', livePullRequestState: 'merged', pullRequestIcon: Codicon.gitPullRequestDone }); + const { activeSession, service } = createHarness(openSession); + + const hasOpenPullRequest = [service.activeSessionStateObs.get()?.hasOpenPullRequest]; + activeSession.set(mergedSession, undefined); + hasOpenPullRequest.push(service.activeSessionStateObs.get()?.hasOpenPullRequest); + activeSession.set(cachedTerminalSession, undefined); + hasOpenPullRequest.push(service.activeSessionStateObs.get()?.hasOpenPullRequest); + activeSession.set(liveTerminalSession, undefined); + hasOpenPullRequest.push(service.activeSessionStateObs.get()?.hasOpenPullRequest); + + assert.deepStrictEqual(hasOpenPullRequest, [true, false, true, false]); + }); }); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index c3e0dbbd4dc8e..05dd28f12566f 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -48,7 +48,7 @@ import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorAreaFocusContext, IsSessionsWindowContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { NEW_SESSION_ACTION_ID } from '../common/constants.js'; -import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; @@ -57,10 +57,8 @@ import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackground'; -const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.clearAgentSessionsBackground'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackgroundLayout'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(IsSessionsWindowContext, SessionsChatBackgroundAvailableContext); -const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundConfiguredContext); const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundImageConfiguredContext); type RecentChatBackgroundTypeItem = IQuickPickItem & { @@ -69,10 +67,14 @@ type RecentChatBackgroundTypeItem = IQuickPickItem & { }; type ChatBackgroundTypeItem = IQuickPickItem & ({ - readonly kind: 'codicons' | 'image'; + readonly kind: 'none' | 'codicons' | 'image'; }) | RecentChatBackgroundTypeItem; const chatBackgroundTypeItems: ChatBackgroundTypeItem[] = [{ + kind: 'none', + label: localize('chat.agentSessions.backgroundType.none.label', "No Background"), + detail: localize('chat.agentSessions.backgroundType.none.detail', "Remove the current chat background."), +}, { kind: 'codicons', label: localize('chat.agentSessions.backgroundType.codicons.label', "Codicons"), detail: localize('chat.agentSessions.backgroundType.codicons.detail', "Use a theme-aware pattern of built-in VS Code icons."), @@ -223,7 +225,7 @@ class SetChatBackgroundAction extends Action2 { const backgroundService = accessor.get(ISessionsChatBackgroundService); const quickInputService = accessor.get(IQuickInputService); const fileDialogService = accessor.get(IFileDialogService); - const backgroundKind = backgroundService.getBackground()?.kind; + const backgroundKind = backgroundService.getBackground()?.kind ?? 'none'; const recentImages = backgroundService.getRecentBackgroundImages(); const recentItems: RecentChatBackgroundTypeItem[] = recentImages.map(image => ({ kind: 'recentImage', @@ -249,6 +251,11 @@ class SetChatBackgroundAction extends Action2 { if (!backgroundType) { return; } + if (backgroundType.kind === 'none') { + await backgroundService.clearBackground(); + status(localize('chat.agentSessions.clearBackground.cleared', "Chat background cleared.")); + return; + } if (backgroundType.kind === 'codicons') { await backgroundService.setBackground(AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET); status(localize('chat.agentSessions.setBackground.codicons', "Chat background set to Codicons.")); @@ -327,35 +334,6 @@ class ChangeChatBackgroundLayoutAction extends Action2 { registerAction2(ChangeChatBackgroundLayoutAction); -class ClearChatBackgroundAction extends Action2 { - - constructor() { - super({ - id: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID, - title: localize2('chat.agentSessions.clearBackground', "Clear Background"), - category: CHAT_CATEGORY, - precondition: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, - menu: [{ - id: MenuId.CommandPalette, - when: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, - }, { - id: Menus.SessionChatBackgroundContext, - group: 'navigation', - order: 3, - when: ContextKeyExpr.and(SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext), - }], - }); - } - - override async run(accessor: ServicesAccessor): Promise { - await accessor.get(ISessionsChatBackgroundService).clearBackground(); - status(localize('chat.agentSessions.clearBackground.cleared', "Chat background cleared.")); - } -} - -registerAction2(ClearChatBackgroundAction); - - // register actions registerAction2(BranchChatSessionAction); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 817cf8b88574b..1427bcea7824c 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -617,10 +617,10 @@ .sessions-chat-attachment-image { width: 13px; height: 13px; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); object-fit: cover; flex-shrink: 0; - margin: 0 3px; + margin: 0 var(--vscode-spacing-size40); } .sessions-chat-attachment-pill { @@ -674,7 +674,8 @@ white-space: nowrap; } -.sessions-chat-attachment-pill .codicon { +.monaco-workbench .sessions-chat-attachment-pill .sessions-chat-attachment-content .codicon, +.monaco-workbench .sessions-chat-attachment-pill .sessions-chat-attachment-open .codicon { font-size: var(--vscode-codiconFontSize-compact); flex-shrink: 0; padding: 0 var(--vscode-spacing-size20); @@ -719,14 +720,14 @@ cursor: pointer; flex-shrink: 0; color: var(--vscode-descriptionForeground); - margin-left: var(--vscode-spacing-size20); + margin-right: var(--vscode-spacing-size20); } .sessions-chat-attachment-pill.openable:hover { background-color: var(--vscode-toolbar-hoverBackground); } -.sessions-chat-attachment-pill .sessions-chat-attachment-remove .codicon { +.monaco-workbench .sessions-chat-attachment-pill .sessions-chat-attachment-remove .codicon { font-size: var(--vscode-codiconFontSize-compact, 12px); padding: 0; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index d8e467295918c..58f9cebbf44e8 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -5,13 +5,15 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { isDark } from '../../../../platform/theme/common/theme.js'; +import { FileThemeIcon, FolderThemeIcon, IThemeService } from '../../../../platform/theme/common/themeService.js'; import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; @@ -34,12 +36,13 @@ import { Schemas } from '../../../../base/common/network.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; -import { IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, isPastedTextArtifact, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, isPastedTextArtifact, isStringVariableEntry, OmittedState, resolveChatContextIcon } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { isLocation } from '../../../../editor/common/languages.js'; import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; import { createImageHoverContent, openPastedTextArtifact } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX } from '../common/newChatContextIds.js'; /** @@ -71,6 +74,7 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt private readonly _attachedContext: IChatRequestVariableEntry[] = []; private _container: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _fileIconThemeScope = this._register(new MutableDisposable()); private readonly _onDidChangeContext = this._register(new Emitter()); readonly onDidChangeContext = this._onDidChangeContext.event; @@ -102,15 +106,18 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, + @IThemeService private readonly themeService: IThemeService, ) { super(); this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + this._register(this.themeService.onDidFileIconThemeChange(() => this._updateRendering())); } // --- Rendering --- renderAttachedContext(container: HTMLElement): void { this._container = container; + this._fileIconThemeScope.value = createFileIconThemableTreeContainerScope(container, this.themeService); this._updateRendering(); } @@ -130,10 +137,21 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt } this._container.style.display = ''; - this._container.classList.add('show-file-icons'); for (const entry of visibleAttachments) { const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill')); + const removeButton = dom.append(pill, dom.$('button.sessions-chat-attachment-remove')); + removeButton.type = 'button'; + removeButton.title = localize('removeAttachment', "Remove"); + removeButton.setAttribute('aria-label', localize('removeNamedAttachment', "Remove {0}", entry.name)); + const removeIcon = dom.append(removeButton, renderIcon(Codicon.closeCompact)); + removeIcon.setAttribute('aria-hidden', 'true'); + this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.KEY_DOWN, e => e.stopPropagation())); + this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, e => { + e.stopPropagation(); + this.removeAttachment(entry.id); + })); + const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; const githubContextResource = entry.id.startsWith(GITHUB_CONTEXT_ID_PREFIX) ? URI.parse(entry.id.slice(GITHUB_CONTEXT_ID_PREFIX.length)) @@ -152,7 +170,7 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt content = dom.append(pill, dom.$('span.sessions-chat-attachment-content')); } if (entry.kind === 'image') { - const icon = dom.append(content, renderIcon(Codicon.fileMedia)); + const icon = dom.append(content, renderIcon(Codicon.fileMediaCompact)); dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); if (imageData) { // Swap the generic icon for a thumbnail once the shared helper @@ -165,7 +183,7 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt this._renderDisposables.add(preview.disposable); } } else if (entry.id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX)) { - const icon = dom.append(content, renderIcon(Codicon.repo)); + const icon = dom.append(content, renderIcon(Codicon.repoCompact)); icon.setAttribute('aria-hidden', 'true'); dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); } else if (entry.icon) { @@ -176,20 +194,42 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt } dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); } else { - const label = this._resourceLabels.create(content, { supportIcons: true }); + const label = this._resourceLabels.create(content); this._renderDisposables.add(label); - if (resource) { + if (resource && (entry.kind === 'file' || entry.kind === 'directory')) { + const fileIconTheme = this.themeService.getFileIconTheme(); label.setFile(resource, { fileKind: entry.kind === 'directory' ? FileKind.FOLDER : FileKind.FILE, hidePath: true, + icon: entry.kind === 'directory' + ? (!fileIconTheme.hasFolderIcons ? FolderThemeIcon : undefined) + : (!fileIconTheme.hasFileIcons ? FileThemeIcon : undefined), }); } else if (isPastedTextArtifact(entry)) { // Matches the workbench paste pill: a file icon for the artifact's // language, and how much text it stands in for. - label.setLabel(entry.fileName, undefined, { extraClasses: ['file-icon', `${entry.language}-lang-file-icon`] }); + label.setLabel(entry.fileName, undefined, this.themeService.getFileIconTheme().hasFileIcons + ? { extraClasses: ['file-icon', `${entry.language}-lang-file-icon`] } + : { extraClasses: getIconClasses(this.modelService, this.languageService, undefined, FileKind.FILE, FileThemeIcon) }); dom.append(content, dom.$('span.sessions-chat-attachment-info', undefined, localize('pastedLines', "Pasted {0}", entry.pastedLines))); } else { - label.setLabel(entry.name); + const iconPath = (isStringVariableEntry(entry) || entry.kind === 'generic') ? entry.iconPath : undefined; + const attachmentLabel = entry.fullName ?? entry.name; + const updateLabel = () => { + if (isStringVariableEntry(entry) && ThemeIcon.isThemeIcon(iconPath) && (ThemeIcon.isFile(iconPath) || ThemeIcon.isFolder(iconPath)) && entry.resourceUri) { + const fileKind = ThemeIcon.isFolder(iconPath) ? FileKind.FOLDER : FileKind.FILE; + label.setLabel(attachmentLabel, undefined, { extraClasses: getIconClasses(this.modelService, this.languageService, entry.resourceUri, fileKind) }); + } else { + const icon = iconPath + ? resolveChatContextIcon(iconPath, isDark(this.themeService.getColorTheme().type)) + : entry.icon ?? Codicon.attachCompact; + label.setLabel(attachmentLabel, undefined, { iconPath: icon }); + } + }; + updateLabel(); + if (iconPath && !ThemeIcon.isThemeIcon(iconPath) && !URI.isUri(iconPath)) { + this._renderDisposables.add(this.themeService.onDidColorThemeChange(updateLabel)); + } } } @@ -212,18 +252,6 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt await this.instantiationService.invokeFunction(openPastedTextArtifact, entry); })); } - - const removeButton = dom.append(pill, dom.$('button.sessions-chat-attachment-remove')); - removeButton.type = 'button'; - removeButton.title = localize('removeAttachment', "Remove"); - removeButton.setAttribute('aria-label', localize('removeNamedAttachment', "Remove {0}", entry.name)); - const removeIcon = dom.append(removeButton, renderIcon(Codicon.closeCompact)); - removeIcon.setAttribute('aria-hidden', 'true'); - this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.KEY_DOWN, e => e.stopPropagation())); - this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, (e) => { - e.stopPropagation(); - this.removeAttachment(entry.id); - })); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index a8d058a7ed0b5..aeb559d626f5c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -127,6 +127,7 @@ import { animatePromptTyping, IPromptTypingAnimation } from './promptTypingAnima import { PromptTemplatePlaceholderController } from './promptTemplatePlaceholder.js'; import { INewSessionComposer, INewSessionPromptOptionsController, NEW_SESSION_PROMPT_TYPING_DURATION_MS, NewSessionPromptOptionsState, NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { NewSessionPromptOptionsWidget } from './newSessionPromptOptions.js'; +import { isInputGitHubContext, toInputGitHubContextMetadata } from '../common/newChatContextIds.js'; const OPEN_OTEL_SETTINGS_COMMAND = 'github.copilot.chat.otel.openSettings'; @@ -134,6 +135,7 @@ const OTEL_STATUS_COMMAND = 'github.copilot.chat.otel.statusActive'; const OTEL_STATUS_ENTRY_ID = 'copilot.otelStatus'; const OTEL_DOCS_URL = 'https://code.visualstudio.com/docs/agents/guides/monitoring-agents'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; +const GITHUB_ISSUE_OR_PULL_REQUEST_URL_PATTERN = /\bhttps?:\/\/(?:www\.)?github\.com\/(?[\w.-]+)\/(?[\w.-]+)\/(?issues|pull)\/(?\d+)\b/gi; const MIN_EDITOR_HEIGHT = 50; const MAX_EDITOR_HEIGHT = 200; const NEW_CHAT_INPUT_FONT_FAMILY = 'system-ui, -apple-system, sans-serif'; @@ -212,6 +214,33 @@ export function hasSendableNewChatContent(query: string, attachments: readonly I return !!query.trim() || attachments.some(isExplicitFileOrImageVariableEntry) || hasAdditionalSendContent; } +function getInputGitHubContextAttachments(input: string): readonly IChatRequestVariableEntry[] { + const attachments: IChatRequestVariableEntry[] = []; + const ids = new Set(); + for (const match of input.matchAll(GITHUB_ISSUE_OR_PULL_REQUEST_URL_PATTERN)) { + const groups = match.groups; + const number = Number(groups?.['number']); + if (!groups || !Number.isSafeInteger(number) || number <= 0) { + continue; + } + const owner = groups['owner']; + const repo = groups['repo']; + const kind = groups['kind'].toLowerCase(); + const uri = `https://github.com/${owner}/${repo}/${kind}/${number}`; + const id = `github-context:${uri}`; + if (ids.has(id)) { + continue; + } + ids.add(id); + attachments.push(toPasteVariableEntry(`${owner}/${repo}#${number}`, `GitHub context: ${uri}`, { + id, + icon: kind === 'issues' ? Codicon.issues : Codicon.gitPullRequest, + _meta: toInputGitHubContextMetadata(), + })); + } + return attachments; +} + class NewChatInputStatusActionViewItem extends MenuEntryActionViewItem { private readonly hoverContentDisposables = this._register(new MutableDisposable()); @@ -996,6 +1025,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation ))); this._register(this._editor.onDidChangeModelContent(() => { + this._syncInputGitHubContext(); this._updateDraftState(); this._updateSendButtonState(); this._updateEditorFontFamily(); @@ -1397,6 +1427,25 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this.saveState(); } + private _syncInputGitHubContext(): void { + const inputAttachments = getInputGitHubContextAttachments(this._editor?.getValue() ?? ''); + const inputAttachmentIds = new Set(inputAttachments.map(attachment => attachment.id)); + const attachments = this._contextAttachments.attachments.filter(attachment => + !isInputGitHubContext(attachment) || inputAttachmentIds.has(attachment.id) + ); + const attachmentIds = new Set(attachments.map(attachment => attachment.id)); + for (const attachment of inputAttachments) { + if (!attachmentIds.has(attachment.id)) { + attachments.push(attachment); + attachmentIds.add(attachment.id); + } + } + if (attachments.length !== this._contextAttachments.attachments.length + || attachments.some((attachment, index) => attachment !== this._contextAttachments.attachments[index])) { + this._contextAttachments.setAttachments(attachments); + } + } + private _toHistoryEntry(draft: IDraftState): IChatModelInputState { return { ...draft, @@ -1534,6 +1583,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation if (draft.attachments?.length) { this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport)); } + this._syncInputGitHubContext(); } this._updateSendButtonState(); } @@ -1768,10 +1818,18 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } attachTextContext(name: string, content: string, icon: ThemeIcon, id = `context:${content}`): void { - this._contextAttachments.addAttachments(toPasteVariableEntry(name, content, { + const attachment = toPasteVariableEntry(name, content, { id, icon, - })); + }); + const index = this._contextAttachments.attachments.findIndex(entry => entry.id === id); + if (index < 0) { + this._contextAttachments.addAttachments(attachment); + } else { + const attachments = [...this._contextAttachments.attachments]; + attachments[index] = attachment; + this._contextAttachments.setAttachments(attachments); + } } addAttachments(...attachments: IChatRequestVariableEntry[]): void { diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index da7b43eff538c..ca4484eb21ec8 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -466,12 +466,7 @@ export class NewChatWidget extends Disposable { if (!target) { return; } - this._newChatInput.sessionTypePicker.render(target, { - className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', - }); - if (!isQuickChat && target.lastElementChild) { - target.insertBefore(target.lastElementChild, target.firstElementChild); - } + this._renderSessionTypePicker(target, isQuickChat); })); } @@ -715,12 +710,7 @@ export class NewChatWidget extends Disposable { workspaceTrigger, gitHubContextTrigger, ]); - this._newChatInput.sessionTypePicker.render(row, { - className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', - }); - if (row.lastElementChild) { - row.insertBefore(row.lastElementChild, row.firstElementChild); - } + this._renderSessionTypePicker(row, false); this._workspacePickerRow = row; return toDisposable(() => { if (this._workspacePickerRow === row) { @@ -729,6 +719,16 @@ export class NewChatWidget extends Disposable { }); } + private _renderSessionTypePicker(container: HTMLElement, isQuickChat: boolean): void { + this._newChatInput.sessionTypePicker.render(container, { + className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', + }); + const sessionTypePicker = container.lastElementChild; + if (!isQuickChat && sessionTypePicker?.previousElementSibling) { + container.insertBefore(sessionTypePicker, sessionTypePicker.previousElementSibling); + } + } + private _renderEmptyState(container: HTMLElement): IDisposable { this._workspacePickerVisibleKey.set(false); const emptyState = this.instantiationService.createInstance(NoAgentHostEmptyState); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 1c33e234e1f82..968d7c1cf6a87 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -51,7 +51,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); - content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose the built-in theme-aware Codicons pattern, choose a new image, or reuse one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Use Clear Background to remove either background. These commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images, and Clear Background is shown only when the current color theme has a background. Background customization is unavailable while a high contrast theme is active.")); + content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose no background, the built-in theme-aware Codicons pattern, a new image, or one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Both commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images. Background customization is unavailable while a high contrast theme is active.")); content.push(localize('sessionsChat.vscodePet', "Use the checked Pet item in the new-session view context menu, or type /vscode-pet, to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.")); content.push(localize('sessionsChat.vscodePetAchievements', "When the pet is enabled, the user account menu lists unlocked achievement badges before locked badges and provides a View Achievements button. A gold star on the pet announces a newly unlocked achievement; activate the pet while the star is visible to open Achievements.")); content.push(localize('sessionsChat.aquariumAction', "To show or hide the aquarium action on the new-session view, use the checked Aquarium item in the context menu outside the composer, or run the Toggle Aquarium Action Visibility command.")); diff --git a/src/vs/sessions/contrib/chat/common/newChatContextIds.ts b/src/vs/sessions/contrib/chat/common/newChatContextIds.ts index 5174ec7e7b899..381ca4d51cebf 100644 --- a/src/vs/sessions/contrib/chat/common/newChatContextIds.ts +++ b/src/vs/sessions/contrib/chat/common/newChatContextIds.ts @@ -7,6 +7,11 @@ import { URI } from '../../../../base/common/uri.js'; export const ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX = 'sessions-additional-folder:'; export const ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX = 'sessions-additional-repository:'; +const INPUT_GITHUB_CONTEXT_METADATA_KEY = 'sessionsInputGitHubContext'; + +interface IHasInputGitHubContextMetadata { + readonly _meta?: Record; +} export function getAdditionalFolderContextId(uri: URI): string { return `${ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX}${uri.toString()}`; @@ -19,3 +24,12 @@ export function getAdditionalRepositoryContextId(uri: URI): string { export function isAdditionalWorkspaceContextId(id: string): boolean { return id.startsWith(ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX) || id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX); } + +export function isInputGitHubContext(source: IHasInputGitHubContextMetadata): boolean { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the input GitHub context slot. + return source._meta?.[INPUT_GITHUB_CONTEXT_METADATA_KEY] === true; +} + +export function toInputGitHubContextMetadata(): Record { + return { [INPUT_GITHUB_CONTEXT_METADATA_KEY]: true }; +} diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 4d6fec7b7b145..246671e3ec468 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -278,6 +278,55 @@ suite('Sessions - Chat View', () => { }); }); + test('keeps existing codicons stable when the background grid resizes', () => { + const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); + const part = dom.append(workbench, dom.$('.part.sessionspart')); + part.style.width = '960px'; + part.style.height = '800px'; + dom.getWindow(workbench).document.body.appendChild(workbench); + disposables.add(toDisposable(() => workbench.remove())); + const renderer = disposables.add(new SessionsChatBackgroundRenderer(part)); + renderer.setBackground({ kind: 'codicons' }); + const layer = part.querySelector(':scope > .sessions-chat-background > .sessions-chat-codicon-background'); + const firstIcon = layer?.querySelector('.codicon'); + const firstIconLeft = firstIcon?.style.left; + const firstIconTop = firstIcon?.style.top; + const initialIconCount = layer?.querySelectorAll('.codicon').length; + + part.style.width = '961px'; + renderer.setBackground({ kind: 'codicons' }); + const expandedFirstIcon = layer?.querySelector('.codicon'); + const expandedIconCount = layer?.querySelectorAll('.codicon').length; + + part.style.width = '960px'; + renderer.setBackground({ kind: 'codicons' }); + const shrunkFirstIcon = layer?.querySelector('.codicon'); + + assert.deepStrictEqual({ + initialIconCount, + expandedIconCount, + shrunkIconCount: layer?.querySelectorAll('.codicon').length, + reusedFirstIconWhenExpanded: expandedFirstIcon === firstIcon, + reusedFirstIconWhenShrunk: shrunkFirstIcon === firstIcon, + firstIconPositions: [ + { left: firstIconLeft, top: firstIconTop }, + { left: expandedFirstIcon?.style.left, top: expandedFirstIcon?.style.top }, + { left: shrunkFirstIcon?.style.left, top: shrunkFirstIcon?.style.top }, + ], + }, { + initialIconCount: 109, + expandedIconCount: 117, + shrunkIconCount: 109, + reusedFirstIconWhenExpanded: true, + reusedFirstIconWhenShrunk: true, + firstIconPositions: [ + { left: '125.6px', top: '46.4px' }, + { left: '125.6px', top: '46.4px' }, + { left: '125.6px', top: '46.4px' }, + ], + }); + }); + test('keeps the user request bubble opaque over the chat background', () => { const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); workbench.style.setProperty('--session-view-background', '#202020'); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index 0a8deaa5de612..6743a1be46e45 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -4,17 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { IIconLabelValueOptions } from '../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { IResolvedTextEditorModel } from '../../../../../editor/common/services/resolverService.js'; +import { FileKind } from '../../../../../platform/files/common/files.js'; +import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; +import { FileThemeIcon, FolderThemeIcon } from '../../../../../platform/theme/common/themeService.js'; +import { IFileLabelOptions } from '../../../../../workbench/browser/labels.js'; import { hasSendableNewChatContent, NewChatInputWidget } from '../../browser/newChatInput.js'; -import { IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatPasteAttachmentMetadata, IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { NewChatContextAttachments } from '../../browser/newChatContextAttachments.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; @@ -36,6 +43,8 @@ const saveState = Reflect.get(NewChatInputWidget.prototype, 'saveState') as (thi const clearDraftState = Reflect.get(NewChatInputWidget.prototype, '_clearDraftState') as (this: IDraftStateHarness) => void; const updateDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateDraftState') as (this: IUpdateDraftStateHarness) => void; const updateAndSaveDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateAndSaveDraftState') as (this: IUpdateAndSaveDraftStateHarness) => void; +const syncInputGitHubContext = Reflect.get(NewChatInputWidget.prototype, '_syncInputGitHubContext') as (this: ISyncInputGitHubContextHarness) => void; +const attachTextContext = Reflect.get(NewChatInputWidget.prototype, 'attachTextContext') as (this: IAttachTextContextHarness, name: string, content: string, icon: ThemeIcon, id: string) => void; const updateSendButtonState = Reflect.get(NewChatInputWidget.prototype, '_updateSendButtonState') as (this: IUpdateSendButtonStateHarness) => void; const setInputEditorFocused = Reflect.get(NewChatInputWidget.prototype, '_setInputEditorFocused') as (container: HTMLElement, focused: boolean) => void; const updateAttachmentRendering = Reflect.get(NewChatContextAttachments.prototype, '_updateRendering') as (this: IAttachmentRenderingHarness) => void; @@ -56,6 +65,7 @@ interface IRestoreStateHarness { readonly _contextAttachments: { setAttachments(entries: readonly IChatRequestVariableEntry[]): void; }; + _syncInputGitHubContext(): void; _updateSendButtonState(): void; } @@ -74,6 +84,24 @@ interface IUpdateAndSaveDraftStateHarness extends IUpdateDraftStateHarness { saveState(): void; } +interface ISyncInputGitHubContextHarness { + readonly _editor: { + getValue(): string; + }; + readonly _contextAttachments: { + attachments: readonly IChatRequestVariableEntry[]; + setAttachments(entries: readonly IChatRequestVariableEntry[]): void; + }; +} + +interface IAttachTextContextHarness { + readonly _contextAttachments: { + attachments: readonly IChatRequestVariableEntry[]; + addAttachments(...entries: IChatRequestVariableEntry[]): void; + setAttachments(entries: readonly IChatRequestVariableEntry[]): void; + }; +} + interface IUpdateSendButtonStateHarness { readonly _sendButton: { enabled: boolean } | undefined; readonly _sending: boolean; @@ -95,14 +123,25 @@ interface IAttachmentRenderingHarness { readonly _renderDisposables: DisposableStore; readonly _resourceLabels: { clear(): void; - create(container: HTMLElement, options: { supportIcons: boolean }): IDisposable & { - setLabel(label: string): void; - setFile(resource: URI, options: object): void; + create(container: HTMLElement): IDisposable & { + setLabel(label: string, description?: string, options?: IIconLabelValueOptions): void; + setFile(resource: URI, options?: IFileLabelOptions): void; }; }; readonly openerService: { open(resource: URI): Promise; }; + readonly themeService?: { + getFileIconTheme(): { hasFileIcons: boolean; hasFolderIcons: boolean }; + getColorTheme(): { type: ColorScheme }; + readonly onDidColorThemeChange: Event; + }; + readonly modelService?: { + getModel(): null; + }; + readonly languageService?: { + guessLanguageIdByFilepathOrFirstLine(): string; + }; removeAttachment(id: string): void; } @@ -260,6 +299,7 @@ suite('NewChatInputWidget', () => { _getDraftState: () => draft, _editor: { getModel: () => ({ setValue: value => restored.inputText = value }) }, _contextAttachments: { setAttachments: entries => restored.attachments = entries }, + _syncInputGitHubContext: () => { }, _updateSendButtonState: () => { }, }); @@ -353,6 +393,7 @@ suite('NewChatInputWidget', () => { }, options: {}, _canSendRequest: { get: () => true }, + _syncInputGitHubContext: () => { }, _updateSendButtonState() { updateSendButtonState.call(this); }, @@ -363,6 +404,89 @@ suite('NewChatInputWidget', () => { assert.strictEqual(sendButton.enabled, true); }); + test('synchronizes GitHub context attachments with issue and pull request links in the input', () => { + let input = 'Fix https://github.com/microsoft/vscode/issues/333845 and review https://www.github.com/microsoft/vscode/pull/333575#discussion.'; + const manualAttachment = toPasteVariableEntry('Manually attached', 'Manual context', { + id: 'github-context:https://github.com/microsoft/vscode/issues/1', + }); + let attachments: readonly IChatRequestVariableEntry[] = [manualAttachment]; + const harness: ISyncInputGitHubContextHarness = { + _editor: { getValue: () => input }, + _contextAttachments: { + get attachments() { return attachments; }, + setAttachments: entries => attachments = entries, + }, + }; + const snapshot = () => attachments.map(attachment => ({ + id: attachment.id, + name: attachment.name, + icon: ThemeIcon.isThemeIcon(attachment.icon) ? attachment.icon.id : undefined, + })); + + syncInputGitHubContext.call(harness); + const withLinks = snapshot(); + input = 'Review https://github.com/microsoft/vscode/pull/333575.'; + syncInputGitHubContext.call(harness); + const afterRemovingIssueLink = snapshot(); + input = ''; + syncInputGitHubContext.call(harness); + + assert.deepStrictEqual({ + withLinks, + afterRemovingIssueLink, + afterRemovingAllLinks: snapshot(), + }, { + withLinks: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + { id: 'github-context:https://github.com/microsoft/vscode/issues/333845', name: 'microsoft/vscode#333845', icon: Codicon.issues.id }, + { id: 'github-context:https://github.com/microsoft/vscode/pull/333575', name: 'microsoft/vscode#333575', icon: Codicon.gitPullRequest.id }, + ], + afterRemovingIssueLink: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + { id: 'github-context:https://github.com/microsoft/vscode/pull/333575', name: 'microsoft/vscode#333575', icon: Codicon.gitPullRequest.id }, + ], + afterRemovingAllLinks: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + ], + }); + }); + + test('preserves pasted GitHub context after the same target is explicitly attached', () => { + const uri = 'https://github.com/microsoft/vscode/issues/333845'; + let input = `Fix ${uri}`; + let attachments: readonly IChatRequestVariableEntry[] = []; + const contextAttachments = { + get attachments() { return attachments; }, + addAttachments: (...entries: IChatRequestVariableEntry[]) => attachments = [...attachments, ...entries], + setAttachments: (entries: readonly IChatRequestVariableEntry[]) => attachments = entries, + }; + const syncHarness: ISyncInputGitHubContextHarness = { + _editor: { getValue: () => input }, + _contextAttachments: contextAttachments, + }; + + syncInputGitHubContext.call(syncHarness); + attachTextContext.call( + { _contextAttachments: contextAttachments }, + 'microsoft/vscode#333845', + `GitHub context: ${uri}`, + Codicon.issues, + `github-context:${uri}`, + ); + input = ''; + syncInputGitHubContext.call(syncHarness); + + assert.deepStrictEqual(attachments.map(attachment => ({ + id: attachment.id, + name: attachment.name, + icon: ThemeIcon.isThemeIcon(attachment.icon) ? attachment.icon.id : undefined, + })), [{ + id: `github-context:${uri}`, + name: 'microsoft/vscode#333845', + icon: Codicon.issues.id, + }]); + }); + test('renders GitHub context pills as openable with a keyboard-reachable remove button', async () => { const container = document.createElement('div'); const entry = toPasteVariableEntry('microsoft/vscode#332825', 'GitHub context: https://github.com/microsoft/vscode/pull/332825', { @@ -486,6 +610,233 @@ suite('NewChatInputWidget', () => { } }); + test('renders leading removal and compact attachment icons', () => { + const container = document.createElement('div'); + const entries: IChatRequestVariableEntry[] = [ + { + kind: 'file', + id: 'file', + name: 'README.md', + value: URI.file('/workspace/README.md'), + }, + { + kind: 'directory', + id: 'directory', + name: 'spritesheet', + value: URI.file('/workspace/spritesheet'), + }, + { + kind: 'generic', + id: 'unknown', + name: 'Unknown context', + value: 'unknown', + }, + { + kind: 'string', + id: 'themed-file', + name: 'Themed file', + value: 'themed-file', + uri: URI.parse('vscode://context/themed-file'), + resourceUri: URI.file('/workspace/src/index.ts'), + iconPath: FileThemeIcon, + handle: 1, + }, + { + kind: 'image', + id: 'image', + name: 'image.png', + value: URI.file('/workspace/image.png'), + }, + ]; + const labels: { label: string; icon?: string; extraClasses?: readonly string[] }[] = []; + const files: { resource: string; fileKind?: FileKind; icon?: string }[] = []; + const renderDisposables = disposables.add(new DisposableStore()); + updateAttachmentRendering.call({ + _container: container, + _attachedContext: entries, + _renderDisposables: renderDisposables, + _resourceLabels: { + clear: () => { }, + create: content => { + const labelElement = document.createElement('span'); + labelElement.className = 'resource-label'; + content.appendChild(labelElement); + return { + dispose: () => { }, + setLabel: (label, _description, options) => labels.push({ + label, + icon: ThemeIcon.isThemeIcon(options?.iconPath) ? options.iconPath.id : options?.iconPath?.toString(), + extraClasses: options?.extraClasses, + }), + setFile: (resource, options) => files.push({ + resource: resource.path, + fileKind: options?.fileKind, + icon: ThemeIcon.isThemeIcon(options?.icon) ? options.icon.id : options?.icon?.toString(), + }), + }; + }, + }, + openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: true, hasFolderIcons: false }), + getColorTheme: () => ({ type: ColorScheme.DARK }), + onDidColorThemeChange: Event.None, + }, + modelService: { + getModel: () => null, + }, + languageService: { + guessLanguageIdByFilepathOrFirstLine: () => 'typescript', + }, + removeAttachment: () => { }, + }); + const firstPill = container.querySelector('.sessions-chat-attachment-pill'); + const openButton = firstPill?.querySelector('.sessions-chat-attachment-open'); + const removeButton = firstPill?.querySelector('.sessions-chat-attachment-remove'); + + assert.deepStrictEqual({ + pillChildren: Array.from(firstPill?.children ?? []).map(child => child.className), + removeButtonNestedInOpenButton: openButton?.contains(removeButton ?? null), + hasCompactImageIcon: !!container.querySelector('.codicon-file-media-compact'), + files, + labels, + }, { + pillChildren: ['sessions-chat-attachment-remove', 'sessions-chat-attachment-open'], + removeButtonNestedInOpenButton: false, + hasCompactImageIcon: true, + files: [ + { resource: '/workspace/README.md', fileKind: FileKind.FILE, icon: undefined }, + { resource: '/workspace/spritesheet', fileKind: FileKind.FOLDER, icon: FolderThemeIcon.id }, + ], + labels: [ + { label: 'Unknown context', icon: Codicon.attachCompact.id, extraClasses: undefined }, + { + label: 'Themed file', + icon: undefined, + extraClasses: ['file-icon', 'src-name-dir-icon', 'index.ts-name-file-icon', 'name-file-icon', 'ts-ext-file-icon', 'ext-file-icon', 'typescript-lang-file-icon'], + }, + ], + }); + }); + + test('updates light and dark attachment icons without rebuilding controls', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const colorThemeEmitter = disposables.add(new Emitter()); + const lightIcon = URI.parse('test:/light.svg'); + const darkIcon = URI.parse('test:/dark.svg'); + let colorScheme = ColorScheme.DARK; + const icons: string[] = []; + const renderDisposables = disposables.add(new DisposableStore()); + updateAttachmentRendering.call({ + _container: container, + _attachedContext: [{ + kind: 'generic', + id: 'themed', + name: 'Themed context', + value: 'themed', + iconPath: { light: lightIcon, dark: darkIcon }, + }], + _renderDisposables: renderDisposables, + _resourceLabels: { + clear: () => { }, + create: content => { + content.appendChild(document.createElement('span')); + return { + dispose: () => { }, + setLabel: (_label, _description, options) => { + if (URI.isUri(options?.iconPath)) { + icons.push(options.iconPath.toString()); + } + }, + setFile: () => { }, + }; + }, + }, + openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: true, hasFolderIcons: true }), + getColorTheme: () => ({ type: colorScheme }), + onDidColorThemeChange: colorThemeEmitter.event, + }, + modelService: { + getModel: () => null, + }, + languageService: { + guessLanguageIdByFilepathOrFirstLine: () => 'typescript', + }, + removeAttachment: () => { }, + }); + const pill = container.querySelector('.sessions-chat-attachment-pill'); + const removeButton = container.querySelector('.sessions-chat-attachment-remove'); + removeButton?.focus(); + + colorScheme = ColorScheme.LIGHT; + colorThemeEmitter.fire(); + + assert.deepStrictEqual({ + icons, + samePill: container.querySelector('.sessions-chat-attachment-pill') === pill, + sameRemoveButton: container.querySelector('.sessions-chat-attachment-remove') === removeButton, + focusedElementPreserved: document.activeElement === removeButton, + }, { + icons: [darkIcon.toString(), lightIcon.toString()], + samePill: true, + sameRemoveButton: true, + focusedElementPreserved: true, + }); + container.remove(); + }); + + test('marks the pasted text fallback as a predefined file icon', () => { + const container = document.createElement('div'); + const entry = toPasteVariableEntry('Pasted text', 'const value = 1;', { + language: 'typescript', + fileName: 'pasted.ts', + pastedLines: '1 line', + _meta: { [ChatPasteAttachmentMetadata.TextArtifact]: true }, + }); + let labelOptions: IIconLabelValueOptions | undefined; + const renderDisposables = disposables.add(new DisposableStore()); + updateAttachmentRendering.call({ + _container: container, + _attachedContext: [entry], + _renderDisposables: renderDisposables, + _resourceLabels: { + clear: () => { }, + create: content => { + content.appendChild(document.createElement('span')); + return { + dispose: () => { }, + setLabel: (_label, _description, options) => labelOptions = options, + setFile: () => { }, + }; + }, + }, + openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: false, hasFolderIcons: false }), + getColorTheme: () => ({ type: ColorScheme.DARK }), + onDidColorThemeChange: Event.None, + }, + modelService: { + getModel: () => null, + }, + languageService: { + guessLanguageIdByFilepathOrFirstLine: () => 'typescript', + }, + removeAttachment: () => { }, + }); + + assert.deepStrictEqual({ + iconPath: labelOptions?.iconPath, + extraClasses: labelOptions?.extraClasses, + }, { + iconPath: undefined, + extraClasses: ['codicon-file', 'predefined-file-icon'], + }); + }); + test('renders additional folder and repository context as attachment pills', () => { const container = document.createElement('div'); const folder = URI.file('/workspace/docs'); @@ -518,6 +869,11 @@ suite('NewChatInputWidget', () => { }), }, openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: true, hasFolderIcons: false }), + getColorTheme: () => ({ type: ColorScheme.DARK }), + onDidColorThemeChange: Event.None, + }, removeAttachment: () => { }, }); @@ -525,10 +881,11 @@ suite('NewChatInputWidget', () => { Array.from(container.querySelectorAll('.sessions-chat-attachment-pill')).map(pill => ({ text: pill.textContent, removeAriaLabel: pill.querySelector('.sessions-chat-attachment-remove')?.getAttribute('aria-label'), + hasCompactRepositoryIcon: !!pill.querySelector('.codicon-repo-compact'), })), [ - { text: 'docs', removeAriaLabel: 'Remove docs' }, - { text: 'microsoft/typescript', removeAriaLabel: 'Remove microsoft/typescript' }, + { text: 'docs', removeAriaLabel: 'Remove docs', hasCompactRepositoryIcon: false }, + { text: 'microsoft/typescript', removeAriaLabel: 'Remove microsoft/typescript', hasCompactRepositoryIcon: true }, ], ); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index e9c2adb165af8..bb10cb56d2acb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -8,7 +8,7 @@ import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -114,20 +114,25 @@ interface ISendHarness { readonly logService: { error(message: string, ...args: unknown[]): void }; } -interface IRenderWorkspacePickerHarness { - readonly _workspacePickerVisibleKey: { set(value: boolean): void }; - readonly _workspacePicker: { - renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean }[]): HTMLElement; - }; +interface IRenderSessionTypePickerHarness { readonly _newChatInput: { readonly sessionTypePicker: { render(container: HTMLElement, options?: { className?: string }): void; }; }; +} + +interface IRenderWorkspacePickerHarness extends IRenderSessionTypePickerHarness { + readonly _workspacePickerVisibleKey: { set(value: boolean): void }; + readonly _workspacePicker: { + renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean }[]): HTMLElement; + }; + _renderSessionTypePicker(container: HTMLElement, isQuickChat: boolean): void; _workspacePickerRow: HTMLElement | undefined; } const renderWorkspacePicker = Reflect.get(NewChatWidget.prototype, '_renderWorkspacePicker') as (this: IRenderWorkspacePickerHarness, container: HTMLElement) => IDisposable; +const renderSessionTypePicker = Reflect.get(NewChatWidget.prototype, '_renderSessionTypePicker') as (this: IRenderSessionTypePickerHarness, container: HTMLElement, isQuickChat: boolean) => void; function createHarness( pendingPreferredUpgrade: MutableDisposable, @@ -158,7 +163,7 @@ function createHarness( suite('NewChatWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('workspace row hosts a multiple-harness picker first', () => { + test('workspace row hosts the workspace picker before the multiple-harness and context pickers', () => { const container = document.createElement('div'); const harnessLabels = ['Copilot', 'Claude']; const workspaceTriggers: { readonly tooltip: string | undefined; readonly icon: string | undefined; readonly attachesContext: boolean | undefined }[] = []; @@ -190,6 +195,7 @@ suite('NewChatWidget', () => { }, }, }, + _renderSessionTypePicker: (target, isQuickChat) => renderSessionTypePicker.call(harness, target, isQuickChat), _workspacePickerRow: undefined, }; @@ -201,8 +207,8 @@ suite('NewChatWidget', () => { className: element.className, })), [ - { label: 'Copilot', className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot' }, { label: 'Workspace', className: '' }, + { label: 'Copilot', className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot' }, { label: 'Issue/PR', className: '' }, ], ); @@ -212,6 +218,51 @@ suite('NewChatWidget', () => { ]); }); + test('restores workspace, harness, context DOM and tab order after quick chat', () => { + const workspaceRow = document.createElement('div'); + const quickChatHeader = document.createElement('div'); + for (const label of ['Workspace', 'Issue/PR']) { + const item = document.createElement('a'); + item.tabIndex = 0; + item.textContent = label; + workspaceRow.appendChild(item); + } + let renderedPicker: HTMLElement | undefined; + const harness: IRenderSessionTypePickerHarness = { + _newChatInput: { + sessionTypePicker: { + render: (target, options) => { + renderedPicker?.remove(); + const item = document.createElement('a'); + item.tabIndex = 0; + item.className = options?.className ?? ''; + item.textContent = 'Copilot'; + target.appendChild(item); + renderedPicker = item; + }, + }, + }, + }; + + const isQuickChat = observableValue('isQuickChat', false); + disposables.add(autorun(reader => { + const value = isQuickChat.read(reader); + renderSessionTypePicker.call(harness, value ? quickChatHeader : workspaceRow, value); + })); + isQuickChat.set(true, undefined); + isQuickChat.set(false, undefined); + + assert.deepStrictEqual({ + domOrder: Array.from(workspaceRow.children, element => element.textContent), + tabOrder: Array.from(workspaceRow.querySelectorAll('[tabindex="0"]'), element => element.textContent), + quickChatHeader: Array.from(quickChatHeader.children, element => element.textContent), + }, { + domOrder: ['Workspace', 'Copilot', 'Issue/PR'], + tabOrder: ['Workspace', 'Copilot', 'Issue/PR'], + quickChatHeader: [], + }); + }); + test('replays a provider change that arrives while creating the draft', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); diff --git a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts index f789606205980..32d7f44126418 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts @@ -41,7 +41,7 @@ export function computeLivePullRequestIcon(reader: IReaderWithStore, gitHubServi } /** Computes the live title and icon used to present a pull request reference. */ -export function computePullRequestRefPresentation(reader: IReaderWithStore, gitHubService: IGitHubService, iconCache: IPullRequestIconCache, pullRequest: IGitHubPullRequestRef, fallbackIcon?: ThemeIcon): Pick { +export function computePullRequestRefPresentation(reader: IReaderWithStore, gitHubService: IGitHubService, iconCache: IPullRequestIconCache, pullRequest: IGitHubPullRequestRef, fallbackIcon?: ThemeIcon): Pick { const prLink = pullRequest.uri.toString(); const prModelRef = reader.store.add(gitHubService.createPullRequestModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number)); const livePullRequest = prModelRef.object.pullRequest.read(reader); @@ -49,10 +49,11 @@ export function computePullRequestRefPresentation(reader: IReaderWithStore, gitH return { icon: iconCache.get(prLink) ?? pullRequest.icon ?? fallbackIcon, title: pullRequest.title, + liveState: undefined, }; } const icon = computeLivePullRequestIcon(reader, gitHubService, pullRequest.owner, pullRequest.repo, livePullRequest); iconCache.set(prLink, icon); - return { icon, title: livePullRequest.title }; + return { icon, title: livePullRequest.title, liveState: livePullRequest.state }; } diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts index 9e9f433c5ece0..1818eaa77a049 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts @@ -207,7 +207,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { if (!group || group.contains(e.editor)) { return; } - void this._sequencer.queue(() => this._removeFilesTab(this._editorGroupsService.mainPart.activeGroup)).catch(onUnexpectedError); + this._queue(() => this._removeFilesTab(this._editorGroupsService.mainPart.activeGroup)); })); this._register(this._editorService.onDidCloseEditor(e => { if (e.editor instanceof EmptyFileEditorInput @@ -245,7 +245,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { } if (visible) { - void this._sequencer.queue(() => this._restoreCollapsedTabs()).catch(onUnexpectedError); + this._queue(() => this._restoreCollapsedTabs()); return; } @@ -254,7 +254,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { return; } if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - void this._sequencer.queue(() => this._collapseNonManagedTabs()).catch(onUnexpectedError); + this._queue(() => this._collapseNonManagedTabs()); } })); @@ -294,7 +294,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { : trigger; this._pending = { sessionKey, target, trigger: mergedTrigger }; const generation = ++this._generation; - void this._sequencer.queue(() => this._reconcile(generation)).catch(onUnexpectedError); + this._queue(() => this._reconcile(generation)); } private _readTarget(reader: IReader | undefined): IManagedTabsTarget { @@ -310,8 +310,24 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // --- Reconcile -------------------------------------------------------- + override dispose(): void { + // Bump the generation before super.dispose() so queued/in-flight reconciles bail at their next checkpoint. + this._generation++; + this._pending = undefined; + super.dispose(); + } + + /** Queues coordinator-owned work, dropping tasks and failures that outlive disposal. */ + private _queue(task: () => Promise): void { + void this._sequencer.queue(() => this._store.isDisposed ? Promise.resolve() : task()).catch(error => { + if (!this._store.isDisposed) { + onUnexpectedError(error); + } + }); + } + private async _reconcile(generation: number): Promise { - if (generation !== this._generation || !this._pending) { + if (this._store.isDisposed || generation !== this._generation || !this._pending) { return; } @@ -334,6 +350,9 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { private async _reconcileCore(target: IManagedTabsTarget, trigger: IReconcileTrigger, generation: number): Promise { const group = this._editorGroupsService.mainPart.activeGroup; + let groupDisposed = false; + const groupDisposeListener = Event.once(group.onWillDispose)(() => groupDisposed = true); + const isCancelled = () => this._store.isDisposed || groupDisposed || generation !== this._generation; this._resetCollapsedEditorsOnSessionChange(); const changesResource = target.changesSessionResource ? this._sessionChangesService.getChangesEditorResource(target.changesSessionResource) : undefined; @@ -345,8 +364,8 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { try { // [1] Replace an outgoing session's Changes tab in place when the incoming // session also wants Changes; close only additional stale tabs. - await this._reconcileForeignChangesEditors(group, changesResource); - if (generation !== this._generation) { + await this._reconcileForeignChangesEditors(group, changesResource, isCancelled); + if (isCancelled()) { return; } this._updateFilesEditors(group, target.workspace); @@ -354,7 +373,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { const preserveMissingFiles = !!trigger.workingSetRestored && this._preserveMissingFilesForSessionKey === sessionKey; if (preserveMissingFiles) { await this._removeFilesTab(group); - if (generation !== this._generation) { + if (isCancelled()) { return; } } @@ -376,14 +395,14 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // [3] Keep Files active by default for a new-session view. if (openFilesFirst) { await this._openFilesTab(group, target.workspace); - if (generation !== this._generation) { + if (isCancelled()) { return; } } // [4] Open Changes (active on submit so the detail panel maps to it). if (openChanges && changesResource) { - if (!await this._openChangesTab(target.changesSessionResource!, changesResource, group, generation, activateChanges)) { + if (!await this._openChangesTab(target.changesSessionResource!, changesResource, group, activateChanges, isCancelled)) { return; } } @@ -391,13 +410,18 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // [5] Open the Files placeholder after Changes for created sessions. if (openFiles && !openFilesFirst) { await this._openFilesTab(group, target.workspace); - if (generation !== this._generation) { + if (isCancelled()) { return; } } + } catch (error) { + if (!this._store.isDisposed && !groupDisposed) { + throw error; + } } finally { suppression.dispose(); - if (generation === this._generation) { + groupDisposeListener.dispose(); + if (!isCancelled()) { if (trigger.workingSetRestored) { this._preserveMissingFilesForSessionKey = undefined; } @@ -417,11 +441,11 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // --- Tab operations --------------------------------------------------- - /** Opens the Changes editor pinned first (active on submit). Returns `false` if a newer reconcile superseded this one mid-open. */ - private async _openChangesTab(sessionResource: URI, changesResource: URI, group: IEditorGroup, generation: number, active: boolean): Promise { + /** Opens the Changes editor pinned first (active on submit). Returns `false` if the reconcile is cancelled mid-open. */ + private async _openChangesTab(sessionResource: URI, changesResource: URI, group: IEditorGroup, active: boolean, isCancelled: () => boolean): Promise { this._changesViewService.setChangesetId(undefined); await this._sessionChangesService.openChangesEditor(sessionResource, active ? CHANGES_TAB_ACTIVE_OPTIONS : CHANGES_TAB_OPTIONS, group); - if (generation !== this._generation) { + if (isCancelled()) { return false; } const changesEditor = this._findChangesEditor(group, changesResource); @@ -455,7 +479,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { } } - private async _reconcileForeignChangesEditors(group: IEditorGroup, activeChangesResource: URI | undefined): Promise { + private async _reconcileForeignChangesEditors(group: IEditorGroup, activeChangesResource: URI | undefined, isCancelled: () => boolean): Promise { const foreign = group.editors.filter(editor => { const resource = this.getChangesEditorResource(editor); return resource && (!activeChangesResource || !isEqual(resource, activeChangesResource)); @@ -476,6 +500,9 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { replacement: this._instantiationService.createInstance(SessionChangesEditorInput, activeChangesResource), options: wasActive ? CHANGES_TAB_ACTIVE_OPTIONS : CHANGES_TAB_OPTIONS, }]); + if (isCancelled()) { + return; + } if (editorsToClose.length > 0) { await this._closeManagedEditors(group, editorsToClose); } @@ -536,7 +563,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { private _queueCollapseIfDetailsOnly(): void { if (!this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) && this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - void this._sequencer.queue(() => this._collapseNonManagedTabs()).catch(onUnexpectedError); + this._queue(() => this._collapseNonManagedTabs()); } } diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index 44f8119702192..5f01ccb083e26 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; +import { errorHandler } from '../../../../../base/common/errors.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ISettableObservable, transaction } from '../../../../../base/common/observable.js'; @@ -2980,6 +2981,126 @@ suite('LayoutController (desktop)', () => { assert.deepStrictEqual(publishedWorkspaces, ['c']); }); + test('[managed tabs / dispose] a reconcile stalled mid-open opens no further editors once the controller is disposed', async () => { + const controller = createSinglePaneController({ activateAux: true }); + await settle(); + + // Pause the reconcile at the first Changes open so it stalls before the Files tab opens. + let releaseChangesOpen!: () => void; + const changesOpenGate = new Promise(resolve => { releaseChangesOpen = resolve; }); + let gateArmed = true; + harness.onOpenChangesEditor = () => { + if (gateArmed) { + gateArmed = false; + return changesOpenGate; + } + return undefined; + }; + + // The created session's reconcile stalls awaiting the gated Changes open before the Files tab. + harness.activeSessionObs.set(makeSession(URI.parse('session:1'), { isCreated: true, changes: [makeChange('/file.ts')] }), undefined); + await settle(); + assert.strictEqual(hasFilesTab(), false, 'reconcile should be stalled before opening the Files tab'); + + // Dispose while stalled: the generation bump on dispose must make the resumed reconcile bail before any later editor open. + controller.dispose(); + releaseChangesOpen(); + await settle(); + + assert.strictEqual(hasFilesTab(), false, 'a reconcile resumed after dispose must not open further editors'); + }); + + test('[managed tabs / dispose] ignores an in-flight editor replacement failure after the controller is disposed', async () => { + const originalUnexpectedErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const unexpectedErrors: Error[] = []; + errorHandler.setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + try { + const controller = createSinglePaneController({ activateAux: true }); + await settle(); + harness.activeSessionObs.set(makeSession(URI.parse('session:a')), undefined); + await settle(); + + let replaceStarted = false; + let rejectReplace!: (error: Error) => void; + const replaceGate = new Promise((_, reject) => { rejectReplace = reject; }); + harness.onReplaceEditors = replacements => { + replaceStarted = true; + store.add(replacements[0].replacement); + return replaceGate; + }; + + harness.activeSessionObs.set(makeSession(URI.parse('session:b')), undefined); + await settle(); + assert.strictEqual(replaceStarted, true, 'the reconcile should be stalled replacing the outgoing Changes editor'); + + controller.dispose(); + rejectReplace(new Error('InstantiationService has been disposed')); + await settle(); + + assert.deepStrictEqual(unexpectedErrors, []); + } finally { + errorHandler.setUnexpectedErrorHandler(originalUnexpectedErrorHandler); + } + }); + + test('[managed tabs / dispose] ignores an in-flight editor replacement failure after the target group is disposed', async () => { + const originalUnexpectedErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const unexpectedErrors: Error[] = []; + errorHandler.setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + try { + createSinglePaneController({ activateAux: true }); + await settle(); + harness.activeSessionObs.set(makeSession(URI.parse('session:a')), undefined); + await settle(); + + let replaceStarted = false; + let rejectReplace!: (error: Error) => void; + const replaceGate = new Promise((_, reject) => { rejectReplace = reject; }); + harness.onReplaceEditors = replacements => { + replaceStarted = true; + store.add(replacements[0].replacement); + return replaceGate; + }; + + harness.activeSessionObs.set(makeSession(URI.parse('session:b')), undefined); + await settle(); + assert.strictEqual(replaceStarted, true, 'the reconcile should be stalled replacing the outgoing Changes editor'); + + harness.onWillDisposeActiveGroup.fire(); + rejectReplace(new Error('InstantiationService has been disposed')); + await settle(); + + assert.deepStrictEqual(unexpectedErrors, []); + } finally { + errorHandler.setUnexpectedErrorHandler(originalUnexpectedErrorHandler); + } + }); + + test('[managed tabs / errors] reports an editor replacement failure while the reconcile is active', async () => { + const originalUnexpectedErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const unexpectedErrors: Error[] = []; + errorHandler.setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + try { + createSinglePaneController({ activateAux: true }); + await settle(); + harness.activeSessionObs.set(makeSession(URI.parse('session:a')), undefined); + await settle(); + + const failure = new Error('replace failed'); + harness.onReplaceEditors = replacements => { + store.add(replacements[0].replacement); + throw failure; + }; + + harness.activeSessionObs.set(makeSession(URI.parse('session:b')), undefined); + await settle(); + + assert.deepStrictEqual(unexpectedErrors, [failure]); + } finally { + errorHandler.setUnexpectedErrorHandler(originalUnexpectedErrorHandler); + } + }); + test('[managed tabs / details-only] always restores both docked inputs while only details are visible', async () => { createSinglePaneController({ activateAux: true, diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 1e337915b040a..4f68574569d03 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -215,6 +215,8 @@ export interface ITestLayoutHarness { activateAux: boolean; /** Editors in the main part's active group (drives the single-pane managed-tab logic). */ activeGroupEditors: EditorInput[]; + /** Fires when the active editor group begins disposal. */ + onWillDisposeActiveGroup: Emitter; /** Records editors closed via `IEditorService.closeEditors`. */ closedEditors: EditorInput[]; /** Records untyped editors reopened via `IEditorService.openEditors`. */ @@ -248,7 +250,7 @@ export interface ITestLayoutHarness { /** Optional async hook awaited before `closeEditors` mutates the group. */ onCloseEditors?: () => Promise | void; /** Optional async hook awaited before `replaceEditors` mutates the group. */ - onReplaceEditors?: () => Promise | void; + onReplaceEditors?: (replacements: IEditorReplacement[]) => Promise | void; /** Records every `openChangesEditor` call for assertions (session + whether active). */ openChangesEditorCalls: { sessionResource: URI; active: boolean }[]; readonly sessionChangesService: ISessionChangesService; @@ -334,6 +336,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption editorPartAutoVisibilitySuppressionDepth: 0, activateAux: options.activateAux ?? false, activeGroupEditors: [], + onWillDisposeActiveGroup: store.add(new Emitter()), closedEditors: [], openedEditors: [], closeSuppressionFlags: [], @@ -356,6 +359,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption const testActiveGroup: IEditorGroup = new class extends mock() { override readonly id = 1; override get editors() { return harness.activeGroupEditors as IEditorGroup['editors']; } + override readonly onWillDispose = harness.onWillDisposeActiveGroup.event; override readonly onWillCloseEditor = harness.onWillCloseEditor.event as IEditorGroup['onWillCloseEditor']; override get count() { return harness.activeGroupEditors.length; } override get isEmpty() { return harness.activeGroupEditors.length === 0; } @@ -365,7 +369,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption override pinEditor() { } override getIndexOfEditor(editor: EditorInput) { return harness.activeGroupEditors.indexOf(editor); } override async replaceEditors(replacements: IEditorReplacement[]) { - await harness.onReplaceEditors?.(); + await harness.onReplaceEditors?.(replacements); for (const replacement of replacements) { const index = harness.activeGroupEditors.indexOf(replacement.editor); if (index === -1) { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 49e428b561557..138c3a1f56d91 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -232,6 +232,8 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { readonly capabilities: ISessionChangesetCapabilities; + private readonly _locallyRunningOperationCounts = observableValue>(this, new Map()); + protected abstract readonly channelUriObs: IObservable; protected abstract readonly changesetStateObs: IObservable>; private readonly _changesetFilesObs: IObservable; @@ -338,7 +340,10 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { }); this.operations = derivedOpts({ equalsFn: arrayEqualsC(structuralEquals) }, reader => { - return operationsObs.read(reader) ?? []; + const locallyRunningOperationCounts = this._locallyRunningOperationCounts.read(reader); + return operationsObs.read(reader).map(operation => locallyRunningOperationCounts.has(operation.id) && operation.status !== SessionChangesetOperationStatus.Running + ? { ...operation, status: SessionChangesetOperationStatus.Running } + : operation); }); } @@ -381,16 +386,34 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { } } - await connection.invokeChangesetOperation({ - operationId, - channel: channel.toString(), - target: target?.kind === 'resource' - ? { - kind: ChangesetOperationTargetKind.Resource, - resource: target.resource.toString() - } - : undefined, - }); + this._setOperationLocallyRunning(operationId, true); + try { + await connection.invokeChangesetOperation({ + operationId, + channel: channel.toString(), + target: target?.kind === 'resource' + ? { + kind: ChangesetOperationTargetKind.Resource, + resource: target.resource.toString() + } + : undefined, + }); + } finally { + this._setOperationLocallyRunning(operationId, false); + } + } + + private _setOperationLocallyRunning(operationId: string, running: boolean): void { + const counts = new Map(this._locallyRunningOperationCounts.get()); + const count = counts.get(operationId) ?? 0; + if (running) { + counts.set(operationId, count + 1); + } else if (count <= 1) { + counts.delete(operationId); + } else { + counts.set(operationId, count - 1); + } + this._locallyRunningOperationCounts.set(counts, undefined); } setReviewState(resources: readonly URI[], reviewed: boolean): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts index 74b15e9da83e6..4ae3c9d7f6e22 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts @@ -208,8 +208,8 @@ abstract class AgentMergeActionBase extends Action2 { } // The primary button only names the Agent Merge actions, so it is contributed -// as a submenu: the changes button bar opens exactly these entries as a context -// menu rather than its own dropdown, which also carries pull request operations. +// as a submenu: its first entry is the primary invocation, while the button's +// dropdown also carries the remaining pull request operations. // // Deliberately not gated on Agent Merge being enabled for the session: the // button stands in for the auto-merge operations either way, and enabling it is @@ -225,8 +225,8 @@ MenuRegistry.appendMenuItem(Menus.ChangesOperationsDropdown, { when: ContextKeyExpr.and(agentMergeMenuPrecondition, agentMergeOwnsPrimaryButton), }); -/** Menus the Agent Merge entries appear on: the operations dropdown, and their own context menu. */ -const agentMergeMenus = [Menus.ChangesOperationsDropdown, Menus.ChangesAgentMerge]; +/** Menus the top-level Agent Merge entries appear on: the operations dropdown, and their own context menu. */ +const agentMergeTopLevelMenus = [Menus.ChangesOperationsDropdown, Menus.ChangesAgentMerge]; registerAction2(class EnableAgentMergeInSessionAction extends AgentMergeActionBase { constructor() { @@ -236,7 +236,7 @@ registerAction2(class EnableAgentMergeInSessionAction extends AgentMergeActionBa // goes, so a check mark next to "Enable" would only be ambiguous. title: localize2('agentMerge.enableInSession', "Enable Agent Merge"), f1: false, - menu: agentMergeMenus.map(id => ({ + menu: agentMergeTopLevelMenus.map(id => ({ id, group: '1_agentMerge', order: 1, @@ -262,7 +262,7 @@ registerAction2(class DisableAgentMergeInSessionAction extends AgentMergeActionB id: 'sessions.agentHost.agentMerge.disableInSession', title: localize2('agentMerge.disableInSession', "Disable Agent Merge"), f1: false, - menu: agentMergeMenus.map(id => ({ + menu: agentMergeTopLevelMenus.map(id => ({ id, group: '1_agentMerge', order: 1, @@ -282,6 +282,16 @@ registerAction2(class DisableAgentMergeInSessionAction extends AgentMergeActionB } }); +for (const id of agentMergeTopLevelMenus) { + MenuRegistry.appendMenuItem(id, { + submenu: Menus.ChangesAgentMergeConfigure, + title: localize2('agentMerge.configure.submenu', "Configure Agent Merge"), + group: '1_agentMerge', + order: 2, + when: agentMergeMenuPrecondition, + }); +} + for (const [index, action] of agentMergeRepairActions.entries()) { registerAction2(class ToggleAgentMergeRepairAction extends AgentMergeActionBase { constructor() { @@ -290,12 +300,12 @@ for (const [index, action] of agentMergeRepairActions.entries()) { title: { value: agentMergeActionLabels[action], original: agentMergeActionLabels[action] }, toggled: AgentMergeSessionActionContexts[action], f1: false, - menu: agentMergeMenus.map(id => ({ - id, - group: '2_agentMergeActions', + menu: [{ + id: Menus.ChangesAgentMergeConfigure, + group: '1_agentMergeActions', order: index, when: agentMergeMenuPrecondition, - })), + }], }); } @@ -314,8 +324,7 @@ for (const [index, action] of agentMergeRepairActions.entries()) { // One submenu entry per value, so the title can name the current choice without // the user having to open it. Exactly one is ever visible. for (const value of agentMergeMergePullRequestValues) { - MenuRegistry.appendMenuItem(Menus.ChangesOperationsDropdown, mergePullRequestSubmenuItem(value)); - MenuRegistry.appendMenuItem(Menus.ChangesAgentMerge, mergePullRequestSubmenuItem(value)); + MenuRegistry.appendMenuItem(Menus.ChangesAgentMergeConfigure, mergePullRequestSubmenuItem(value)); } function mergePullRequestSubmenuItem(value: AgentMergeMergePullRequest): ISubmenuItem { @@ -323,7 +332,7 @@ function mergePullRequestSubmenuItem(value: AgentMergeMergePullRequest): ISubmen return { submenu: Menus.ChangesAgentMergeMergePullRequest, title: { value: title, original: title }, - group: '2_agentMergeActions', + group: '1_agentMergeActions', order: agentMergeRepairActions.length, when: ContextKeyExpr.and(agentMergeMenuPrecondition, AgentMergeSessionMergePullRequestContext.isEqualTo(value)), }; @@ -358,12 +367,12 @@ registerAction2(class OpenAgentMergeDefaultsAction extends Action2 { id: 'sessions.agentHost.agentMerge.openDefaults', title: localize2('agentMerge.openDefaults', "Agent Merge Defaults"), f1: false, - menu: agentMergeMenus.map(id => ({ - id, - group: '3_agentMergeDefaults', + menu: [{ + id: Menus.ChangesAgentMergeConfigure, + group: '2_agentMergeDefaults', order: 1, when: agentMergeMenuPrecondition, - })), + }], }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 54f2cfbc11759..4b1c66b6ebfeb 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -318,9 +318,13 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine x.number === y.number && isEqual(x.uri, y.uri) && x.icon?.id === y.icon?.id && + x.state === y.state && + x.liveState === y.liveState && x.title === y.title) && a.pullRequest?.number === b.pullRequest?.number && a.pullRequest?.icon?.id === b.pullRequest?.icon?.id && + a.pullRequest?.state === b.pullRequest?.state && + a.pullRequest?.liveState === b.pullRequest?.liveState && a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && a.pullRequest?.headRefOid === b.pullRequest?.headRefOid && @@ -354,7 +358,7 @@ function toGitHubIssueRefs(issueUrls: readonly string[] | undefined): readonly I * title. Every pull request published here belongs to the session — it either * produced it or its branch relates to it — so all are marked as such. */ -function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap): readonly IGitHubPullRequestRef[] | undefined { +function toGitHubPullRequestRefs(state: ISessionGitHubState | undefined, pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap): readonly IGitHubPullRequestRef[] | undefined { const refs: IGitHubPullRequestRef[] = []; for (const url of pullRequestUrls ?? []) { const reference = parseGitHubPullRequestUrl(url); @@ -363,6 +367,7 @@ function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined, refs.push({ ...reference, uri: URI.parse(url), + state: state?.pullRequestStateUrl && linkKey(state.pullRequestStateUrl) === linkKey(url) ? state.pullRequestState : undefined, ...(title ? { title } : {}), createdByThisSession: true, }); @@ -387,7 +392,7 @@ function toGitHubPromotion(meta: SessionMeta | undefined): IGitHubPromotion { // Only pull requests the session produced are promoted, so the ones it // recorded lead the discovered ones and the first is the main pull request. - const allPullRequests = toGitHubPullRequestRefs(dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles); + const allPullRequests = toGitHubPullRequestRefs(state, dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles); const repository = state?.owner && state.repo ? { owner: state.owner, repo: state.repo } : gitState?.githubOwner && gitState.githubRepo @@ -423,6 +428,7 @@ function toGitHubPromotion(meta: SessionMeta | undefined): IGitHubPromotion { pullRequest: pullRequest ? { number: pullRequest.number, uri: pullRequest.uri, + state: pullRequest.state, } : undefined, issues: issues?.length ? issues : undefined, }, @@ -996,6 +1002,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { ) })); const icon = pullRequests[0].icon; + const liveState = pullRequests[0].liveState; const title = pullRequests[0].title; return { ...baseGitHubInfo, @@ -1003,6 +1010,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { pullRequest: { ...baseGitHubInfo.pullRequest, icon, + liveState, title, } }; @@ -4967,6 +4975,17 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement ); } + private _keepChatSessionStateAlive(chatChannel: string): void { + const parsedChat = parseChatUri(chatChannel); + if (!parsedChat) { + return; + } + const cached = this._sessionCache.get(AgentSession.id(parsedChat.session)); + if (cached) { + this._keepSessionStateAlive(cached.sessionId); + } + } + /** * Lazily acquire a session-state subscription for `sessionId` so that * `_runningSessionConfigs` is seeded from the AHP `SessionState.config` @@ -5662,6 +5681,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } if (e.action.type === ActionType.ChatTurnComplete && isChatAction(e.action)) { + this._keepChatSessionStateAlive(e.channel); this._refreshSessions(); } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { this._handleTitleChanged(e.channel, e.action.title); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts index 65448ea723cc1..23725580883da 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts @@ -10,6 +10,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { AgentHostContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { AgentHostTerminalContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.js'; +import { AgentHostCopilotCliSettingsContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.js'; import { AgentHostAllowSignedOutWhenUsableContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAllowSignedOutWhenUsableContribution.js'; import { AgentHostSdkSetupNotificationContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; import { AgentHostSignedOutModelsNotificationContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSignedOutModelsNotification.js'; @@ -88,6 +89,9 @@ class LocalAgentHostContribution extends Disposable implements IWorkbenchContrib registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); +// Forwards the `chat.agentHost.copilot.*` settings into the host's root config. Without it those +// gates never reach the host here, so features like the Auto routing-profile picker stay off. +registerWorkbenchContribution2(AgentHostCopilotCliSettingsContribution.ID, AgentHostCopilotCliSettingsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostAllowSignedOutWhenUsableContribution.ID, AgentHostAllowSignedOutWhenUsableContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSignedOutModelsNotificationContribution.ID, AgentHostSignedOutModelsNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSdkSetupNotificationContribution.ID, AgentHostSdkSetupNotificationContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts index cbd6bcfb5095d..25d7c6fdf1cc5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts @@ -4,16 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { IReference } from '../../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../../base/common/observable.js'; import { isLinux } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import type { InvokeChangesetOperationResult } from '../../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; +import { ChangesetOperationScope, ChangesetOperationStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ChangesetStatus, StateComponents, type ChangesetState, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IChatSessionFileChange2 } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISessionFileChange } from '../../../../../services/sessions/common/session.js'; +import { ISessionFileChange, SessionChangesetOperationStatus } from '../../../../../services/sessions/common/session.js'; import { createChangesets, filterChangesToPrimaryWorkingDirectory, IAgentHostChangeset } from '../../browser/agentHostSessionChangesets.js'; import { IAgentHostAdapterOptions } from '../../browser/baseAgentHostSessionsProvider.js'; @@ -222,4 +231,66 @@ suite('AgentHostSessionChangesets', () => { ['uncommitted*']); }); }); + + test('marks an invoked operation running locally until the host request settles', async () => { + const operationId = 'create-pr-auto-merge'; + const operationResult = new DeferredPromise(); + const changesetState: ChangesetState = { + status: ChangesetStatus.Ready, + files: [], + operations: [{ + id: operationId, + label: 'Create PR (Auto-Merge)', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }], + }; + const connection = new class extends mock() { + override getSubscription(): IReference> { + const subscription = new class extends mock>() { + override readonly value = changesetState as ComponentToState[T]; + override readonly verifiedValue = changesetState as ComponentToState[T]; + override readonly onDidChange = Event.None; + override readonly onWillApplyAction = Event.None; + override readonly onDidApplyAction = Event.None; + }(); + return { + object: subscription, + dispose: () => { }, + }; + } + + override invokeChangesetOperation(): Promise { + return operationResult.p; + } + }(); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) }); + const options: IAgentHostAdapterOptions = { + icon: Codicon.copilot, + loading: constObservable(false), + buildWorkspace: () => undefined, + instantiationService, + getConnection: () => connection, + agentCapabilities: constObservable(undefined), + mapBackendSessionResource: resource => resource, + }; + const changeset = createChangesets( + URI.parse('ahp-session:/session-1'), + options, + constObservable(true), + [{ label: 'Session Changes', changeKind: ChangesetKind.Session, uriTemplate: 'changeset:/session-1' }], + )[0]; + + const invocation = changeset.invokeOperation(operationId); + const whileRunning = changeset.operations.get().map(operation => ({ id: operation.id, status: operation.status })); + operationResult.complete({}); + await invocation; + const afterCompletion = changeset.operations.get().map(operation => ({ id: operation.id, status: operation.status })); + + assert.deepStrictEqual({ whileRunning, afterCompletion }, { + whileRunning: [{ id: operationId, status: SessionChangesetOperationStatus.Running }], + afterCompletion: [{ id: operationId, status: SessionChangesetOperationStatus.Idle }], + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts index 5e1a43de9a53a..121cf5307e9e7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { isIMenuItem, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; +import { isIMenuItem, isISubmenuItem, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; import { Context } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; import { AgentHostPullRequestOperationId } from '../../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { AgentMergeSettingId } from '../../../../../../platform/agentHost/common/agentMerge.js'; @@ -19,20 +19,20 @@ suite('Agent Merge Actions', () => { ensureNoDisposablesAreLeakedInTestSuite(); /** A session where Agent Merge applies: agent host provider, live, with an open pull request. */ - function createContext(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean }): Context { + function createContext(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean; readonly hasOpenPullRequest?: boolean }): Context { const context = new Context(1, null); context.setValue(IsSessionsWindowContext.key, true); context.setValue(ChatContextKeys.enabled.key, true); context.setValue(SessionProviderIdContext.key, 'local-agent-host'); context.setValue(SessionIsArchivedContext.key, options.archived ?? false); context.setValue(`config.${AgentMergeSettingId.Enabled}`, options.featureEnabled ?? true); - context.setValue(SessionHasOpenPullRequestContext.key, true); + context.setValue(SessionHasOpenPullRequestContext.key, options.hasOpenPullRequest ?? true); context.setValue(SessionPrimaryPullRequestOperationContext.key, options.primaryOperation); context.setValue(SessionAgentMergeEnabledContext.key, options.agentMergeEnabled); return context; } - function ownsPrimaryButton(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean }): boolean { + function ownsPrimaryButton(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean; readonly hasOpenPullRequest?: boolean }): boolean { const item = MenuRegistry.getMenuItems(Menus.ChangesOperationsDropdown) .find(entry => !isIMenuItem(entry) && entry.submenu === Menus.ChangesAgentMerge); assert.ok(item, 'Agent Merge is contributed to the changes operations dropdown'); @@ -76,9 +76,11 @@ suite('Agent Merge Actions', () => { assert.deepStrictEqual({ featureOff: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.EnableAutoMerge, agentMergeEnabled: false, featureEnabled: false }), archived: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.EnableAutoMerge, agentMergeEnabled: true, archived: true }), + mergeCompleted: ownsPrimaryButton({ primaryOperation: '', agentMergeEnabled: true, hasOpenPullRequest: false }), }, { featureOff: false, archived: false, + mergeCompleted: false, }); }); @@ -102,4 +104,48 @@ suite('Agent Merge Actions', () => { disableWhileOn: true, }); }); + + test('the Agent Merge primary menu places its toggle before the configuration submenu', () => { + const visibleEntries = (menu: typeof Menus.ChangesAgentMerge, agentMergeEnabled: boolean) => MenuRegistry.getMenuItems(menu) + .filter(entry => entry.group === '1_agentMerge') + .filter(entry => entry.when?.evaluate(createContext({ primaryOperation: AgentHostPullRequestOperationId.EnableAutoMerge, agentMergeEnabled })) ?? true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(entry => isIMenuItem(entry) ? entry.command.id : entry.submenu.id); + + assert.deepStrictEqual({ + operationsWhileOff: visibleEntries(Menus.ChangesOperationsDropdown, false), + operationsWhileOn: visibleEntries(Menus.ChangesOperationsDropdown, true), + primaryWhileOff: visibleEntries(Menus.ChangesAgentMerge, false), + primaryWhileOn: visibleEntries(Menus.ChangesAgentMerge, true), + }, { + operationsWhileOff: ['sessions.agentHost.agentMerge.enableInSession', Menus.ChangesAgentMergeConfigure.id], + operationsWhileOn: ['sessions.agentHost.agentMerge.disableInSession', Menus.ChangesAgentMergeConfigure.id], + primaryWhileOff: ['sessions.agentHost.agentMerge.enableInSession', Menus.ChangesAgentMergeConfigure.id], + primaryWhileOn: ['sessions.agentHost.agentMerge.disableInSession', Menus.ChangesAgentMergeConfigure.id], + }); + }); + + test('all Agent Merge configuration entries are nested under Configure Agent Merge', () => { + const topLevelConfigurationCommands = [Menus.ChangesOperationsDropdown, Menus.ChangesAgentMerge] + .flatMap(menu => MenuRegistry.getMenuItems(menu)) + .filter(isIMenuItem) + .map(item => item.command.id) + .filter(id => id.startsWith('sessions.agentHost.agentMerge.toggle.') || id === 'sessions.agentHost.agentMerge.openDefaults'); + const configurationItems = MenuRegistry.getMenuItems(Menus.ChangesAgentMergeConfigure); + + assert.deepStrictEqual({ + topLevelConfigurationCommands, + commands: configurationItems.filter(isIMenuItem).map(item => item.command.id), + submenus: configurationItems.filter(isISubmenuItem).map(item => item.submenu.id), + }, { + topLevelConfigurationCommands: [], + commands: [ + 'sessions.agentHost.agentMerge.toggle.addressReviews', + 'sessions.agentHost.agentMerge.toggle.fixCI', + 'sessions.agentHost.agentMerge.toggle.resolveConflicts', + 'sessions.agentHost.agentMerge.openDefaults', + ], + submenus: Array(3).fill(Menus.ChangesAgentMergeMergePullRequest.id), + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index fbcba7d4676a6..bcb50b161297d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -5532,6 +5532,52 @@ suite('LocalAgentHostSessionsProvider', () => { defaultChatTitle: 'Renamed Default', }); }); + + test('turn completion refreshes main chat status after the session-state subscription expires', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-complete'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-complete').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const subagentChat = buildSubagentChatUri(sessionUri, 'tc-1'); + const stateWithStatus = (status: ProtocolSessionStatus): SessionState => makeState([ + makeChatSummary(defaultChat, '', status), + { + ...makeChatSummary(subagentChat, 'Reviewer', status), + origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tc-1' }, + }, + ], { defaultChat }); + + agentHost.setSessionState('multi-complete', 'copilotcli', stateWithStatus(ProtocolSessionStatus.InProgress)); + await timeout(31_000); + agentHost.setSessionState('multi-complete', 'copilotcli', stateWithStatus(ProtocolSessionStatus.Idle)); + const staleStatus = session.mainChat.get().status.get(); + + agentHost.fireAction({ + channel: subagentChat, + action: { + type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: 1000, + }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + await timeout(0); + + assert.deepStrictEqual({ + staleStatus, + updatedStatus: session.mainChat.get().status.get(), + subagentStatus: session.chats.get().find(chat => chat.origin?.kind === ChatOriginKind.Tool)?.status.get(), + subscribeCount: agentHost.sessionSubscribeCounts.get(sessionUri), + unsubscribeCount: agentHost.sessionUnsubscribeCounts.get(sessionUri), + }, { + staleStatus: SessionStatus.InProgress, + updatedStatus: SessionStatus.Completed, + subagentStatus: SessionStatus.Completed, + subscribeCount: 2, + unsubscribeCount: 1, + }); + })); }); // ---- Title change from server ------- diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index c975ffebbcea5..a3af5d4431b5c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -566,6 +566,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis default: false, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, }, 'chat.sshRemoteAgentHostCommand': { type: 'string', diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index a66ae6c3ec429..d8618ae91b27d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -26,12 +27,22 @@ import { IAICustomizationWorkspaceService } from '../../../../../../workbench/co import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js'; import { CustomizationHarnessServiceBase, IHarnessDescriptor } from '../../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { MockPromptsService } from '../../../../../../workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.js'; +import { MockPromptsService as BaseMockPromptsService } from '../../../../../../workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { IAgentHostCustomizationService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { AgentCustomizationItemProvider } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; +class MockPromptsService extends BaseMockPromptsService { + override getDisabledPromptFiles(): ResourceSet { + return new ResourceSet(); + } + + override async listPromptFilesForStorage(): Promise<[]> { + return []; + } +} + class MockAgentConnection extends mock() { private readonly _onDidAction = new Emitter(); @@ -236,6 +247,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [pluginA, pluginB]), + new MockPromptsService(), )); const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); @@ -253,6 +265,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); provider.setDraftCustomAgents(observableValue('draftAgents', [{ type: CustomizationType.Agent, @@ -294,6 +307,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [hostScoped]), + new MockPromptsService(), )); connection.fireAction({ @@ -336,6 +350,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [hostPlugin]), + new MockPromptsService(), )); connection.fireAction({ @@ -424,6 +439,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -469,6 +485,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -515,6 +532,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [pluginRef]), + new MockPromptsService(), )); connection.fireAction({ @@ -553,6 +571,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [pluginRef]), + new MockPromptsService(), )); let changeCount = 0; @@ -635,6 +654,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -703,6 +723,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [plugin]), + new MockPromptsService(), )); const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); @@ -761,6 +782,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -811,6 +833,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ channel: agentHostSessionId, @@ -870,6 +893,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -928,6 +952,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [plugin]), + new MockPromptsService(), )); const harnessId = 'remote-agent-host-test'; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts index f2817fc080976..cb808d2880140 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts @@ -19,6 +19,11 @@ import { ensureDevContainerAgentHostsEnabled, isDevContainerWorkspaceAvailable } suite('Dev Container Agent Host Connector', () => { ensureNoDisposablesAreLeakedInTestSuite(); + const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + // Capture these before configuration registry tests clear global registrations. + const devContainerAgentHostEnabledProperty = configurationRegistry.getConfigurationProperties()[DevContainerAgentHostEnabledSettingId]; + const devContainerWorktreeEnabledProperty = configurationRegistry.getExcludedConfigurationProperties()[DevContainerWorktreeEnabledSettingId]; + test('requires Docker and a default Dev Container configuration', async () => { const workspaceUri = URI.file('/workspace'); const check = (existingPaths: readonly string[], dockerAvailable: boolean, devContainerAgentHostsEnabled = true, remoteAgentHostsEnabled = true, uri = workspaceUri) => { @@ -59,14 +64,11 @@ suite('Dev Container Agent Host Connector', () => { }); test('registers an experimental, disabled-by-default user setting', () => { - const property = Registry.as(ConfigurationExtensions.Configuration) - .getConfigurationProperties()[DevContainerAgentHostEnabledSettingId]; - assert.deepStrictEqual({ - default: property.default, - scope: property.scope, - tags: property.tags, - experiment: property.experiment, + default: devContainerAgentHostEnabledProperty.default, + scope: devContainerAgentHostEnabledProperty.scope, + tags: devContainerAgentHostEnabledProperty.tags, + experiment: devContainerAgentHostEnabledProperty.experiment, }, { default: false, scope: ConfigurationScope.APPLICATION, @@ -76,14 +78,11 @@ suite('Dev Container Agent Host Connector', () => { }); test('registers a hidden experimental setting for combining Dev Containers and worktrees', () => { - const property = Registry.as(ConfigurationExtensions.Configuration) - .getExcludedConfigurationProperties()[DevContainerWorktreeEnabledSettingId]; - assert.deepStrictEqual({ - default: property.default, - scope: property.scope, - tags: property.tags, - experiment: property.experiment, + default: devContainerWorktreeEnabledProperty.default, + scope: devContainerWorktreeEnabledProperty.scope, + tags: devContainerWorktreeEnabledProperty.tags, + experiment: devContainerWorktreeEnabledProperty.experiment, }, { default: false, scope: ConfigurationScope.APPLICATION, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts index 5a51f890cf51d..fc46a989b638f 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -337,6 +337,7 @@ suite('Sessions rename', () => { hasPermanentDelete: content.includes('open its context menu and choose Delete'), hasDevContainerAvailability: content.includes('Docker is available') && content.includes('selected local folder contains a Dev Container configuration'), hasDevContainerExecution: content.includes('run the session on an Agent Host inside that folder\'s Dev Container'), + hasNoBackgroundOption: content.includes('choose no background'), hasPetAchievements: content.includes('View Achievements'), activeElement: mainWindow.document.activeElement, fallbackFocusCount: fallbackFocusCount(), @@ -349,6 +350,7 @@ suite('Sessions rename', () => { hasPermanentDelete: true, hasDevContainerAvailability: true, hasDevContainerExecution: true, + hasNoBackgroundOption: true, hasPetAchievements: true, activeElement: origin, fallbackFocusCount: 0, diff --git a/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts b/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts index 33594c23a8470..e22a4fdb5d413 100644 --- a/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts +++ b/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts @@ -61,6 +61,7 @@ export class SessionsChatBackgroundRenderer extends Disposable { private readonly backgroundLayer: HTMLElement; private readonly codiconLayer: HTMLElement; + private readonly codiconCells = new Map(); private background: ISessionsChatBackground | undefined; private codiconGridSize: string | undefined; @@ -112,6 +113,7 @@ export class SessionsChatBackgroundRenderer extends Disposable { this.renderCodicons(this.element.clientWidth, this.element.clientHeight); } else { this.codiconGridSize = undefined; + this.codiconCells.clear(); clearNode(this.codiconLayer); } } @@ -130,24 +132,39 @@ export class SessionsChatBackgroundRenderer extends Disposable { this.codiconGridSize = gridSize; const fragment = this.element.ownerDocument.createDocumentFragment(); + const visibleCells = new Set(); for (let row = 0; row < rows; row++) { for (let column = 0; column < columns; column++) { if (hashCodiconCell(row, column, 0) % 9 === 0) { continue; } + + const cell = `${row}:${column}`; + visibleCells.add(cell); + if (this.codiconCells.has(cell)) { + continue; + } + const icon = renderIcon(codiconChoices[hashCodiconCell(row, column, 1) % codiconChoices.length]); icon.ariaHidden = 'true'; const horizontalOffset = ((hashCodiconCell(row, column, 2) % 71) - 35) / 100; const verticalOffset = ((hashCodiconCell(row, column, 3) % 65) - 32) / 100; const rotation = (hashCodiconCell(row, column, 4) % 71) - 35; - icon.style.left = `${((column + 0.5 + horizontalOffset) / columns) * 100}%`; - icon.style.top = `${((row + 0.5 + verticalOffset) / rows) * 100}%`; + icon.style.left = `${(column + 0.5 + horizontalOffset) * codiconCellSize}px`; + icon.style.top = `${(row + 0.5 + verticalOffset) * codiconCellSize}px`; icon.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`; icon.style.opacity = `${0.65 + (hashCodiconCell(row, column, 5) % 36) / 100}`; + this.codiconCells.set(cell, icon); fragment.append(icon); } } - clearNode(this.codiconLayer); + + for (const [cell, icon] of this.codiconCells) { + if (!visibleCells.has(cell)) { + icon.remove(); + this.codiconCells.delete(cell); + } + } this.codiconLayer.append(fragment); } } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 08b8004ed1560..e18e23c2dfa46 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -304,6 +304,10 @@ export interface IGitHubInfo { readonly number: number; /** URI of the pull request. */ readonly uri: URI; + /** Last host-observed pull request state. */ + readonly state?: 'open' | 'closed' | 'merged'; + /** State from the live workbench pull request model, when resolved. */ + readonly liveState?: 'open' | 'closed' | 'merged'; /** Icon reflecting the PR state. */ readonly icon?: ThemeIcon; /** Pull request title, when known. */ @@ -332,6 +336,10 @@ export interface IGitHubPullRequestRef { readonly uri: URI; /** Icon reflecting the last known PR state. */ readonly icon?: ThemeIcon; + /** Last host-observed pull request state. */ + readonly state?: 'open' | 'closed' | 'merged'; + /** State from the live workbench pull request model, when resolved. */ + readonly liveState?: 'open' | 'closed' | 'merged'; /** * Pull request title, when the session recorded one. Absent for pull requests * discovered from git state, which carry no title until they are fetched live. @@ -358,6 +366,8 @@ export function getGitHubPullRequestRefs(gitHubInfo: IGitHubInfo | undefined): r number: gitHubInfo.pullRequest.number, uri: gitHubInfo.pullRequest.uri, icon: gitHubInfo.pullRequest.icon, + state: gitHubInfo.pullRequest.state, + liveState: gitHubInfo.pullRequest.liveState, title: gitHubInfo.pullRequest.title, }]; } @@ -421,6 +431,13 @@ export type ISessionTurnFileChange = ISessionFileChange & { */ export const BRANCH_CHANGES_CHANGESET_ID = 'branchChanges'; +/** + * Well-known id of the changeset that holds uncommitted working-tree changes. + * + * Must match the agent host provider's `ChangesetKind.Uncommitted` value. + */ +export const UNCOMMITTED_CHANGES_CHANGESET_ID = 'uncommitted'; + /** * Well-known id of the changeset that holds the diff made during the session's * **last turn** only (as opposed to the cumulative session diff). Consumers that @@ -1018,11 +1035,15 @@ export function gitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | und x.repo === y.repo && x.number === y.number && isEqual(x.uri, y.uri) && + x.state === y.state && + x.liveState === y.liveState && x.title === y.title && x.createdByThisSession === y.createdByThisSession && (x.icon === y.icon || (!!x.icon && !!y.icon && ThemeIcon.isEqual(x.icon, y.icon)))) && a.pullRequest?.number === b.pullRequest?.number && isEqual(a.pullRequest?.uri, b.pullRequest?.uri) && + a.pullRequest?.state === b.pullRequest?.state && + a.pullRequest?.liveState === b.pullRequest?.liveState && (aIcon === bIcon || (!!aIcon && !!bIcon && ThemeIcon.isEqual(aIcon, bIcon))) && a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && diff --git a/src/vs/workbench/browser/window.ts b/src/vs/workbench/browser/window.ts index 09cddff295647..7f35363630771 100644 --- a/src/vs/workbench/browser/window.ts +++ b/src/vs/workbench/browser/window.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isSafari, setFullscreen } from '../../base/browser/browser.js'; +import { isSafari, isMobileStandalone, setFullscreen } from '../../base/browser/browser.js'; import { addDisposableListener, EventHelper, EventType, getWindow, getWindowById, getWindows, getWindowsCount, hasAppFocus, windowOpenNoOpener, windowOpenPopup, windowOpenWithSuccess } from '../../base/browser/dom.js'; import { DomEmitter } from '../../base/browser/event.js'; import { HidDeviceData, requestHidDevice, requestSerialPort, requestUsbDevice, SerialPortData, UsbDeviceData } from '../../base/browser/deviceAccess.js'; @@ -355,7 +355,9 @@ export class BrowserWindow extends BaseWindow { // HTTP(s): open in new window and deal with potential popup blockers if (matchesScheme(href, Schemas.http) || matchesScheme(href, Schemas.https)) { - if (isSafari) { + // Both block popups opened outside a user gesture, so use the + // open-then-navigate path, which can pick up a reserved window. + if (isSafari || isMobileStandalone()) { const opened = windowOpenWithSuccess(href, !isAllowedOpener); if (!opened) { await this.dialogService.prompt({ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index a5a281f743ac3..a254b31da13c4 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -21,6 +21,7 @@ import './transcriptsView/voiceTranscripts.contribution.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { PolicyCategory } from '../../../../base/common/policy.js'; import { URI } from '../../../../base/common/uri.js'; import * as nls from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -35,7 +36,7 @@ import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 import { ConfigurationKeyValuePairs, IConfigurationMigrationRegistry, Extensions as WorkbenchConfigurationExtensions } from '../../../common/configuration.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { AgentsVoiceSettingId, AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING, AGENTS_VOICE_ENABLED, AGENTS_VOICE_ENTITLED, AGENTS_VOICE_LISTENING, AGENTS_VOICE_RECONNECTING } from '../common/agentsVoice.js'; +import { AgentsVoiceSettingId, AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING, AGENTS_VOICE_ENABLED, AGENTS_VOICE_ENTITLED, AGENTS_VOICE_LISTENING, AGENTS_VOICE_RECONNECTING, getAgentsVoicePolicyValue } from '../common/agentsVoice.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -597,6 +598,18 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], scope: ConfigurationScope.APPLICATION, restricted: true, + policy: { + name: 'AgentsVoice', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.137', + value: getAgentsVoicePolicyValue, + localization: { + description: { + key: 'agents.voice.enabled', + value: nls.localize('agents.voice.enabled', "Enable the Voice Mode panel in the chat view for voice-driven coding conversations."), + }, + }, + }, }, [AgentsVoiceSettingId.ShowButton]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts index 942931740b51b..47d9306e21da2 100644 --- a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts +++ b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts @@ -5,6 +5,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { Event } from '../../../../base/common/event.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; @@ -34,6 +35,10 @@ export const AGENTS_VOICE_ENABLED = ContextKeyExpr.and( AGENTS_VOICE_ENTITLED, )!; +export function getAgentsVoicePolicyValue(policyData: IPolicyData): false | undefined { + return policyData.chat_preview_features_enabled === false ? false : undefined; +} + export const enum AgentsVoiceSettingId { ShowButton = 'agents.voice.showButton', } diff --git a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts index 4e52daaea5832..f60ff9c9027fd 100644 --- a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts +++ b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts @@ -17,7 +17,7 @@ import { IAccessibilityService } from '../../../../../platform/accessibility/com import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { AgentsVoiceStorageKeys } from '../../common/agentsVoice.js'; +import { AgentsVoiceStorageKeys, getAgentsVoicePolicyValue } from '../../common/agentsVoice.js'; import { IVoiceSessionController, VoiceState } from '../../../chat/browser/voiceClient/voiceSessionController.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { VoiceModeOnboardingBanner, VoiceModeOnboardingService } from '../../browser/voiceModeOnboarding.js'; @@ -92,6 +92,18 @@ suite('Voice Mode onboarding', () => { return store.add(instantiationService.createInstance(VoiceModeOnboardingService)); } + test('disables Voice Mode when preview features are disabled by policy', () => { + assert.deepStrictEqual([ + getAgentsVoicePolicyValue({ chat_preview_features_enabled: false }), + getAgentsVoicePolicyValue({ chat_preview_features_enabled: true }), + getAgentsVoicePolicyValue({}), + ], [ + false, + undefined, + undefined, + ]); + }); + test('auditions a voice, dismisses, and never returns', () => { const telemetryEvents: ITelemetryEvent[] = []; const service = createService(disposables, [], [], telemetryEvents); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index ef6ae27f9cdda..4bb35e476f1b7 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -37,6 +37,7 @@ import { IBrowserViewDevToolsStateEvent, IBrowserViewService, BrowserViewStorageScope, + isInMemoryStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, @@ -397,6 +398,7 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidKeyCommand: Event; readonly onDidChangeTitle: Event; readonly onDidChangeFavicon: Event; + readonly onDidChangeOwner: Event; readonly onDidFindInPage: Event; readonly onDidChangeVisibility: Event; readonly onDidClose: Event; @@ -423,6 +425,7 @@ export interface IBrowserViewModel extends IDisposable { stopFindInPage(keepSelection?: boolean): Promise; getSelectedText(): Promise; clearStorage(): Promise; + setOwner(owner: IBrowserViewOwner): Promise; setSharedWithAgent(shared: boolean): Promise; trustCertificate(host: string, fingerprint: string): Promise; untrustCertificate(host: string, fingerprint: string): Promise; @@ -441,6 +444,7 @@ export interface IBrowserViewModel extends IDisposable { export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _url: string = ''; + private _owner: IBrowserViewOwner; private _title: string = ''; private _favicon: string | undefined = undefined; private _screenshot: VSBuffer | undefined = undefined; @@ -454,7 +458,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _certificateError: IBrowserViewCertificateError | undefined = undefined; private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral; private _isRemoteSession: boolean = false; - private _isEphemeral: boolean = false; + private _isInMemory: boolean = false; private _zoomHost: string | undefined = undefined; private _sharedWithAgent: boolean = false; private _browserZoomIndex: number = browserZoomDefaultIndex; @@ -482,7 +486,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { constructor( readonly id: string, - readonly owner: IBrowserViewOwner, + owner: IBrowserViewOwner, readonly associatedResource: URI | undefined, initialState: IBrowserViewState, private readonly browserViewService: IBrowserViewService, @@ -495,6 +499,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { @ILogService private readonly logService: ILogService, ) { super(); + this._owner = owner; // Initialize state this._url = initialState.url; @@ -516,7 +521,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._isAreaSelectionActive = initialState.isAreaSelectionActive; this._device = initialState.device; this._sharedWithAgent = initialState.audiences.some(audience => audience.type === 'agent'); - this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; + this._isInMemory = isInMemoryStorageScope(this._storageScope); this._zoomHost = parseZoomHost(this._url); const { history: entriesKey, favicons: faviconsKey } = initialState.storageKeys; @@ -540,7 +545,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { snapshot => this.permissions.hydrate(snapshot))); // Sync initial zoom - const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral); + const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isInMemory); if (effectiveZoomIndex !== this._browserZoomIndex) { void this.setBrowserZoomIndex(effectiveZoomIndex).catch(e => { this.logService.warn(`[BrowserViewModel] Failed to set initial zoom:`, e); @@ -548,13 +553,13 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } // Set up state synchronization - this._register(this.zoomService.onDidChangeZoom(({ host, isEphemeralChange }) => { - if (isEphemeralChange && !this._isEphemeral) { + this._register(this.zoomService.onDidChangeZoom(({ host, isInMemoryChange }) => { + if (isInMemoryChange && !this._isInMemory) { return; } if (host === undefined || host === this._zoomHost) { void this.setBrowserZoomIndex( - this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral) + this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isInMemory) ).catch(() => { }); } })); @@ -575,7 +580,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { // Always forceApply because Chromium resets zoom on cross-origin navigation, // and an origin change may not correspond to a host change (e.g. http→https). void this.setBrowserZoomIndex( - this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral), + this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isInMemory), true ); })); @@ -597,6 +602,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._favicon = e.favicon; })); + this._register(this.onDidChangeOwner(owner => { + this._owner = owner; + })); + this._register(this.onDidChangeFocus(({ focused }) => { this._focused = focused; })); @@ -637,6 +646,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } get url(): string { return this._url; } + get owner(): IBrowserViewOwner { return this._owner; } get title(): string { return this._title; } get favicon(): string | undefined { return this._favicon; } get loading(): boolean { return this._loading; } @@ -691,6 +701,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidChangeFavicon(this.id); } + get onDidChangeOwner(): Event { + return this.browserViewService.onDynamicDidChangeOwner(this.id); + } + get onDidFindInPage(): Event { return this.browserViewService.onDynamicDidFindInPage(this.id); } @@ -783,6 +797,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.clearStorage(this.id); } + async setOwner(owner: IBrowserViewOwner): Promise { + return this.browserViewService.setOwner(this.id, owner); + } + async trustCertificate(host: string, fingerprint: string): Promise { return this.browserViewService.trustCertificate(this.id, host, fingerprint); } @@ -836,7 +854,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } await this.setBrowserZoomIndex(this._browserZoomIndex + 1); if (this._zoomHost) { - this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isEphemeral); + this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isInMemory); } } @@ -846,7 +864,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } await this.setBrowserZoomIndex(this._browserZoomIndex - 1); if (this._zoomHost) { - this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isEphemeral); + this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isInMemory); } } @@ -854,7 +872,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { const defaultIndex = this.zoomService.getEffectiveZoomIndex(undefined, false); await this.setBrowserZoomIndex(defaultIndex); if (this._zoomHost) { - this.zoomService.setHostZoomIndex(this._zoomHost, defaultIndex, this._isEphemeral); + this.zoomService.setHostZoomIndex(this._zoomHost, defaultIndex, this._isInMemory); } } diff --git a/src/vs/workbench/contrib/browserView/common/browserZoomService.ts b/src/vs/workbench/contrib/browserView/common/browserZoomService.ts index c0e3cc5023a62..fa82ec35e8f82 100644 --- a/src/vs/workbench/contrib/browserView/common/browserZoomService.ts +++ b/src/vs/workbench/contrib/browserView/common/browserZoomService.ts @@ -30,26 +30,26 @@ export interface IBrowserZoomChangeEvent { readonly host: string | undefined; /** - * Whether the change came from an ephemeral session. - * - `true` → only ephemeral views need to react. - * - `false` → all views (ephemeral and non-ephemeral) for the host may be affected. + * Whether the change came from an in-memory session. + * - `true` → only in-memory views need to react. + * - `false` → all views for the host may be affected. */ - readonly isEphemeralChange: boolean; + readonly isInMemoryChange: boolean; } /** * Manages two independent cascading zoom hierarchies for integrated browser views: * - * Normal views: `persistent per-host override` ?? `configured default` - * Ephemeral views: `ephemeral per-host override` ?? `configured default` + * Persistent views: `persistent per-host override` ?? `configured default` + * In-memory views: `in-memory per-host override` ?? `configured default` * - * Ephemeral views never see persistent overrides directly. Instead, when a persistent - * value changes, it is copied into the ephemeral map so that ephemeral views - * immediately reflect the new level. Conversely, ephemeral changes never affect - * normal views. + * In-memory views never see persistent overrides directly. Instead, when a persistent + * value changes, it is copied into the in-memory map so that in-memory views + * immediately reflect the new level. Conversely, in-memory changes never affect + * persistent views. * * Per-host values that equal the current default are always removed (both persistent - * and ephemeral), so the view tracks the default going forward. + * and in-memory), so the view tracks the default going forward. */ export interface IBrowserZoomService { readonly _serviceBrand: undefined; @@ -61,20 +61,20 @@ export interface IBrowserZoomService { * Returns the effective zoom index for the given host and session type. * Pass `host = undefined` to obtain only the configured default zoom index. */ - getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number; + getEffectiveZoomIndex(host: string | undefined, isInMemory: boolean): number; /** * Set the zoom for a host. * - * Non-ephemeral: persisted to storage. Also propagated into - * the ephemeral map so ephemeral views immediately reflect the change. + * Persistent: persisted to storage. Also propagated into + * the in-memory map so in-memory views immediately reflect the change. * - * Ephemeral: stored in memory only, dropped on restart. + * In-memory: stored in memory only, dropped on restart. * * In both cases, if the value equals the current default, the entry is removed so the * view tracks the default going forward. */ - setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void; + setHostZoomIndex(host: string, zoomIndex: number, isInMemory: boolean): void; /** * Notifies the service of the application's current UI zoom factor. @@ -106,7 +106,7 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic private _persistentZoomMap: Record; /** In-memory only; dropped on restart. */ - private readonly _ephemeralZoomMap = new Map(); + private readonly _inMemoryZoomMap = new Map(); private _windowZoomFactor: number = zoomLevelToZoomFactor(0); // default: zoom level 0 → factor 1.0 @@ -120,17 +120,17 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('workbench.browser.pageZoom')) { - this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false }); + this._onDidChangeZoom.fire({ host: undefined, isInMemoryChange: false }); } })); } - getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number { + getEffectiveZoomIndex(host: string | undefined, isInMemory: boolean): number { if (host !== undefined) { - if (isEphemeral) { - const ephemeralIndex = this._ephemeralZoomMap.get(host); - if (ephemeralIndex !== undefined) { - return this._clamp(ephemeralIndex); + if (isInMemory) { + const inMemoryIndex = this._inMemoryZoomMap.get(host); + if (inMemoryIndex !== undefined) { + return this._clamp(inMemoryIndex); } } else { const persistentIndex = this._persistentZoomMap[host]; @@ -143,24 +143,24 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic return this._getDefaultZoomIndex(); } - setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void { + setHostZoomIndex(host: string, zoomIndex: number, isInMemory: boolean): void { const clamped = this._clamp(zoomIndex); const defaultIndex = this._getDefaultZoomIndex(); const matchesDefault = clamped === defaultIndex; - if (isEphemeral) { + if (isInMemory) { if (matchesDefault) { - if (!this._ephemeralZoomMap.has(host)) { + if (!this._inMemoryZoomMap.has(host)) { return; } - this._ephemeralZoomMap.delete(host); + this._inMemoryZoomMap.delete(host); } else { - if (this._ephemeralZoomMap.get(host) === clamped) { + if (this._inMemoryZoomMap.get(host) === clamped) { return; } - this._ephemeralZoomMap.set(host, clamped); + this._inMemoryZoomMap.set(host, clamped); } - this._onDidChangeZoom.fire({ host, isEphemeralChange: true }); + this._onDidChangeZoom.fire({ host, isInMemoryChange: true }); } else { let persistentChanged = false; if (matchesDefault) { @@ -173,22 +173,22 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic persistentChanged = true; } - // Propagate to ephemeral map so ephemeral views immediately reflect the new level. - let ephemeralChanged = false; + // Propagate to the in-memory map so temporary views immediately reflect the new level. + let inMemoryChanged = false; if (matchesDefault) { - ephemeralChanged = this._ephemeralZoomMap.delete(host); - } else if (this._ephemeralZoomMap.get(host) !== clamped) { - this._ephemeralZoomMap.set(host, clamped); - ephemeralChanged = true; + inMemoryChanged = this._inMemoryZoomMap.delete(host); + } else if (this._inMemoryZoomMap.get(host) !== clamped) { + this._inMemoryZoomMap.set(host, clamped); + inMemoryChanged = true; } - if (!persistentChanged && !ephemeralChanged) { + if (!persistentChanged && !inMemoryChanged) { return; } if (persistentChanged) { this._writePersistentZoomMap(); } - this._onDidChangeZoom.fire({ host, isEphemeralChange: false }); + this._onDidChangeZoom.fire({ host, isInMemoryChange: false }); } } @@ -196,7 +196,7 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic this._windowZoomFactor = windowZoomFactor; const label = this.configurationService.getValue('workbench.browser.pageZoom'); if (label === MATCH_WINDOW_ZOOM_LABEL) { - this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false }); + this._onDidChangeZoom.fire({ host: undefined, isInMemoryChange: false }); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts index 08f43c5b462e1..3258101bd6bc9 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -121,9 +121,38 @@ class ClearEphemeralBrowserStorageAction extends Action2 { } } +class ClearAgentBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearAgentStorage; + + constructor() { + super({ + id: ClearAgentBrowserStorageAction.ID, + title: localize2('browser.clearAgentStorageAction', 'Clear Storage (Agent)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Agent), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Data, + order: 20, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Agent), + isHiddenByDefault: true, + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.model?.clearStorage(); + } + } +} + registerAction2(ClearGlobalBrowserStorageAction); registerAction2(ClearWorkspaceBrowserStorageAction); registerAction2(ClearEphemeralBrowserStorageAction); +registerAction2(ClearAgentBrowserStorageAction); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ ...workbenchConfigurationNodeBase, @@ -134,13 +163,15 @@ Registry.as(ConfigurationExtensions.Configuration).regis 'default', BrowserViewStorageScope.Global, BrowserViewStorageScope.Workspace, - BrowserViewStorageScope.Ephemeral + BrowserViewStorageScope.Ephemeral, + BrowserViewStorageScope.Agent ], markdownEnumDescriptions: [ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.default' }, '`global` for local workspaces, `workspace` for remote workspaces.'), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces. Incompatible with remote sessions.'), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.agent' }, 'Browser views share one in-memory session that is cleaned up when the application is closed. This session is shared with agent-opened browser pages, so **all data including local storage and cookies will be accessible by agents.**') ], restricted: true, default: 'default', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts index 4271552380c6a..ae08b0c940fed 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts @@ -346,7 +346,11 @@ class ShowBrowserHistoryAction extends Action2 { static readonly ID = BrowserViewCommandId.ShowHistory; constructor() { - const when = ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral).negate()); + const when = ContextKeyExpr.and( + BROWSER_EDITOR_ACTIVE, + ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral).negate(), + ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Agent).negate(), + ); super({ id: ShowBrowserHistoryAction.ID, title: localize2('browser.showHistory', 'History'), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index e6e5c69760c17..03eda20533303 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -27,6 +27,7 @@ import { BrowserChatToolReferenceName } from '../../../../../platform/browserVie import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult, getSessionId, remoteUrlRewriteNotice, rewriteRemoteLocalhostUrl } from './browserToolHelpers.js'; import { IRemoteExplorerService } from '../../../../services/remote/common/remoteExplorerService.js'; import { getAgentBrowserViewCreationDefaults } from '../../../../../platform/browserView/common/browserView.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; export const OpenPageToolId = 'open_browser_page'; const OPEN_PAGE_READY_TIMEOUT_MS = 5000; @@ -76,6 +77,7 @@ export class OpenBrowserTool implements IToolImpl { @IChatService private readonly chatService: IChatService, @IConfigurationService private readonly configService: IConfigurationService, @ILogService private readonly logService: ILogService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -287,7 +289,7 @@ export class OpenBrowserTool implements IToolImpl { private async _openNewPage(sessionId: string, url: string): Promise { const input = await this.browserViewService.createBrowserView({ - ...getAgentBrowserViewCreationDefaults(sessionId), + ...getAgentBrowserViewCreationDefaults(sessionId, this.environmentService.isSessionsWindow ? sessionId : undefined), initialUrl: url, openSource: 'cdpCreated' }, { preserveFocus: true }); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts index c43a987af1ea1..38f6e37c14e89 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts @@ -21,6 +21,7 @@ import { BrowserEditorInput } from '../../../common/browserEditorInput.js'; import { BrowserViewStorageScope, IBrowserViewEditorOpenOptions } from '../../../../../../platform/browserView/common/browserView.js'; import { IToolInvocation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; suite('OpenBrowserTool', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -40,6 +41,7 @@ suite('OpenBrowserTool', () => { upcastPartial({}), configService, upcastPartial({}), + upcastPartial({ isSessionsWindow: false }), ); const urls = [ @@ -88,6 +90,7 @@ suite('OpenBrowserTool', () => { upcastPartial({}), new TestConfigurationService(), upcastPartial({}), + upcastPartial({ isSessionsWindow: true }), ); await tool.invoke( @@ -105,7 +108,7 @@ suite('OpenBrowserTool', () => { owner: { type: 'agent', sessionId: 'chat:session' }, initialAudiences: [{ type: 'agent' }], session: { - scope: BrowserViewStorageScope.Ephemeral, + scope: BrowserViewStorageScope.Agent, affinity: 'chat:session' }, initialUrl: 'https://example.com', diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index aef97094b8b40..1df662c1deda0 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -158,6 +158,8 @@ export function getToolSpecificDataDescription(toolSpecificData: ToolSpecificDat return toolSpecificData.operation === 'created' ? localize('automationConfigured.created', "Created an automation: {0}", toolSpecificData.automationName) : localize('automationConfigured.updated', "Edited an automation: {0}", toolSpecificData.automationName); + case 'sessionCreated': + return toolSpecificData.fullTitle ?? toolSpecificData.label; default: return ''; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index f4efe138f8e77..c0c6dc804d0ef 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { autorun, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { basename, dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, type CustomizationDisabledReason } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { CustomizationLoadStatus, CustomizationType, type AgentCustomization, type ChildCustomization, type ClientPluginCustomization, type Customization, type CustomizationLoadState, type DirectoryCustomization, PluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -23,7 +23,7 @@ import { PromptsType, Target } from '../../../common/promptSyntax/promptTypes.js import { AgentCustomizationContentExpander } from './agentCustomizationContentExpander.js'; import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { type ISyncedCustomizationOrigin } from './syncedCustomizationBundler.js'; -import { IAgentSource, ICustomAgent, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage as PromptStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { localize } from '../../../../../../nls.js'; import { getAgentHostPluginEnablementActions } from '../../agentPluginActions.js'; @@ -53,6 +53,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, @IAgentHostCustomizationService private readonly _customAgentsService: IAgentHostCustomizationService, + @IPromptsService private readonly _promptsService: IPromptsService, ) { super(); this._contentExpander = new AgentCustomizationContentExpander(this._fileService, this._logService); @@ -60,6 +61,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto this._register(this._customAgentsService.onDidChangeCustomizations(() => { this._onDidChange.fire(); })); + this._register(this._promptsService.onDidChangeSkills(() => { + this._onDidChange.fire(); + })); } setDraftCustomAgents(customAgents: IObservable): void { @@ -219,7 +223,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto enabled: true, // fill default/empty values for all other properties they will not be used by the UI // when making a request, all that's needed is the agent id. - source: { storage: PromptsStorage.local } satisfies IAgentSource, + source: { storage: PromptStorage.local } satisfies IAgentSource, tools: undefined, agents: undefined, argumentHint: undefined, @@ -324,6 +328,31 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto }); } } + + const disabledBuiltinSkills = this._promptsService.getDisabledPromptFiles(PromptsType.skill); + const builtinSkills = await this._promptsService.listPromptFilesForStorage(PromptsType.skill, PromptStorage.builtIn, token); + if (token.isCancellationRequested) { + return []; + } + for (const skill of builtinSkills) { + if (!disabledBuiltinSkills.has(skill.uri)) { + continue; + } + const key = skill.uri.toString(); + const existing = items.get(key); + items.set(key, { + ...existing, + uri: skill.uri, + type: PromptsType.skill, + name: skill.name ?? existing?.name ?? basename(dirname(skill.uri)), + description: skill.description ?? existing?.description, + source: AICustomizationSources.builtin, + extensionId: undefined, + pluginUri: undefined, + enabled: false, + userInvocable: true, + }); + } return [...items.values()]; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index dd3277b73509e..bae6b6cc485f0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; +import { readAgentModelNoticesMeta } from '../../../../../../platform/agentHost/common/agentModelNotices.js'; import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { readAgentModelPricingMeta } from '../../../../../../platform/agentHost/common/agentModelPricing.js'; import { readAgentModelByokIdentifier } from '../../../../../../platform/agentHost/common/agentModelByokMeta.js'; @@ -68,6 +70,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu const multiplierNumeric = pricing.multiplierNumeric; // "Auto" advertises the auto-mode discount (detail) + description (tooltip). microsoft/vscode#321778, #321659. const isAuto = m.id === AUTO_RAW_MODEL_ID; + const notices = isAuto ? undefined : readAgentModelNoticesMeta(m); const discountPercent = pricing.discountPercent; // Guard against a non-finite or out-of-range value from the open `_meta` bag so we never render // nonsense like "Infinity% discount"; the documented range is a whole number in (0, 100]. @@ -75,9 +78,9 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu const detail = isAuto && hasDiscount ? localize('agentHost.auto.discount', "{0}% discount", discountPercent) : undefined; - const tooltip = isAuto + const tooltip = notices?.rowWarning ?? (isAuto ? ILanguageModelChatMetadata.getAutoModelDescription(hasDiscount ? discountPercent : undefined) - : undefined; + : undefined); const modelGroup = this._modelGroupFor(m); const byokModelIdentifier = readAgentModelByokIdentifier(m); return { @@ -95,6 +98,9 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu maxOutputTokens: m.maxOutputTokens ?? 0, isDefaultForLocation: {}, isUserSelectable: true, + statusIcon: notices?.rowWarning ? Codicon.warning : undefined, + warningText: notices?.warningText, + infoText: notices?.infoText, pricing: multiplierNumeric !== undefined ? `${multiplierNumeric}x` : undefined, multiplierNumeric, inputCost: pricing.inputCost, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index b09da01d5b5df..18e5781b97d56 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -71,13 +71,6 @@ export interface ILocalCustomizationFile { * (to render disable affordances) and the agent host wire (to compute the * `customizations` set published via `activeClientSet`). * - * A file counts as opted out when the per-harness sync provider has it disabled, - * or when the user disabled it in the Customizations UI - * (`IPromptsService.getDisabledPromptFiles`) and that customization is one the - * UI can re-enable ({@link isUserToggleableCustomization}). See "Enabling and - * Disabling Built-in Skills" in `src/vs/sessions/AI_CUSTOMIZATIONS.md` for why - * the second store is scoped rather than honoured for every prompt type. - * * Built-in skills bundled with the Agents app (only present when the * sessions-aware prompts service is in play) are also enumerated so that * `/create-pr`, `/merge`, etc. are available to every agent host without @@ -103,7 +96,7 @@ export async function enumerateLocalCustomizationsForHarness( ); for (let i = 0; i < lists.length; i++) { const source = storageSources[i]; - const honourUserDisabled = isUserToggleableCustomization(type, source); + const userToggleable = isUserToggleableCustomization(type, source); for (const file of lists[i]) { if (matchesSessionType(file.sessionTypes, sessionType) && !seenUris.has(file.uri)) { seenUris.add(file.uri); @@ -113,8 +106,7 @@ export async function enumerateLocalCustomizationsForHarness( source, pluginUri: file.pluginUri, extensionId: file.extension?.identifier.value, - disabled: syncProvider.isDisabled(file.uri) - || (honourUserDisabled && userDisabled.has(file.uri)), + disabled: syncProvider.isDisabled(file.uri) || (userToggleable && userDisabled.has(file.uri)), }); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 9e50ecd1e2a4c..1ede7615d6058 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -91,7 +91,7 @@ export function getAgentSessionProviderIcon(provider: AgentSessionTarget): Theme case AgentSessionProviders.Growth: return Codicon.lightbulb; case AgentSessionProviders.AgentHostCopilot: - return Codicon.vm; + return Codicon.copilot; default: return Codicon.extensions; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 29810ad2459ee..188785b1fcb76 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -254,90 +254,6 @@ export class AICustomizationItemNormalizer { // #endregion -// #region Built-in skills - -/** - * Merges built-in skills (bundled with the app under `vs/sessions/skills/`) - * into an item provider's items, deduped by URI so a copy the provider - * re-discovered is replaced by the authoritative entry, and tagged - * `groupKey: BUILTIN_STORAGE`. User-authored overrides (different URI, same - * name) are preserved. - * - * `enabled` is derived from `getDisabledPromptFiles` alone, so a built-in that - * the wire dropped from the agent-host bundle stays listed as disabled and can - * be re-enabled. This is the restore path that `isUserToggleableCustomization` - * guards, and it is why that predicate must stay in sync with what this merges - * back. See "Enabling and Disabling Built-in Skills" in - * `src/vs/sessions/AI_CUSTOMIZATIONS.md`. - * - * A workbench that uses the base `PromptsService` contributes no built-in - * skills, so `builtinPaths` is empty and the items are returned unchanged. - */ -export async function mergeBuiltinSkills( - items: readonly IAICustomizationListItem[], - promptType: PromptsType, - promptsService: IPromptsService, - _workspaceService: IAICustomizationWorkspaceService, - itemNormalizer: AICustomizationItemNormalizer, -): Promise { - const builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = await promptsService.listPromptFilesForStorage(PromptsType.skill, PromptsStorage.builtIn, CancellationToken.None); - if (builtinPaths.length === 0) { - return [...items]; - } - - const builtinUris = new ResourceMap(); - for (const p of builtinPaths) { - builtinUris.set(p.uri, p); - } - - // Drop provider items that are the same URI as a built-in (the provider - // re-discovered the bundled copy by scanning disk). - const deduped = items.filter(item => !builtinUris.has(item.uri)); - - // Collect names of user/workspace skills so we can hide the built-in - // copy once the user has added an override at either level. - const overriddenNames = new Set(); - for (const item of deduped) { - if (item.source === AICustomizationSources.local || item.source === AICustomizationSources.user) { - if (item.name) { - overriddenNames.add(item.name); - } - } - } - - // Append authoritative built-in entries (excluding any that have been - // overridden by a workspace or user copy with the same name). - const uriUseCounts = new ResourceMap(); - for (const item of deduped) { - uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); - } - const appended: IAICustomizationListItem[] = []; - const disabledPromptFiles = promptsService.getDisabledPromptFiles(PromptsType.skill); - for (const p of builtinPaths) { - const name = p.name ?? basename(p.uri); - if (overriddenNames.has(name)) { - continue; - } - const builtinItem: ICustomizationItem = { - uri: p.uri, - type: PromptsType.skill, - name, - description: p.description, - source: AICustomizationSources.builtin, - groupKey: BUILTIN_STORAGE, - enabled: !disabledPromptFiles.has(p.uri), - extensionId: undefined, - pluginUri: undefined, - userInvocable: true, - }; - appended.push(itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); - } - - return [...deduped, ...appended]; -} - -// #endregion - /** * Item source backed by a session-scoped customization item provider. */ @@ -407,7 +323,7 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); if (promptType === PromptsType.skill) { - return mergeBuiltinSkills(normalized, promptType, this.promptsService, this.workspaceService, this.itemNormalizer); + return this.mergeBuiltinSkills(normalized, promptType); } return normalized; } @@ -420,6 +336,74 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati return (await this.itemProvider.provideSourceFolders(this.sessionResource, promptType, CancellationToken.None)) ?? []; } + /** + * Merges built-in skills (bundled with the app under `vs/sessions/skills/`) + * into the provider's items. The provider may re-discover the bundled + * copies when scanning disk — those duplicates are dropped (deduped by + * URI) and replaced with the authoritative built-in entry tagged + * `groupKey: BUILTIN_STORAGE` so the UI renders them in the "Built-in" + * group. User-authored overrides (different URI, same name) are preserved. + * + * A workbench that uses the base `PromptsService` contributes no built-in + * skills, so `builtinPaths` is empty and the items are returned unchanged. + */ + private async mergeBuiltinSkills(items: readonly IAICustomizationListItem[], promptType: PromptsType): Promise { + const builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = await this.promptsService.listPromptFilesForStorage(PromptsType.skill, PromptsStorage.builtIn, CancellationToken.None); + if (builtinPaths.length === 0) { + return [...items]; + } + + const builtinUris = new ResourceMap(); + for (const p of builtinPaths) { + builtinUris.set(p.uri, p); + } + + // Drop provider items that are the same URI as a built-in (the provider + // re-discovered the bundled copy by scanning disk). + const deduped = items.filter(item => !builtinUris.has(item.uri)); + + // Collect names of user/workspace skills so we can hide the built-in + // copy once the user has added an override at either level. + const overriddenNames = new Set(); + for (const item of deduped) { + if (item.source === AICustomizationSources.local || item.source === AICustomizationSources.user) { + if (item.name) { + overriddenNames.add(item.name); + } + } + } + + // Append authoritative built-in entries (excluding any that have been + // overridden by a workspace or user copy with the same name). + const uriUseCounts = new ResourceMap(); + for (const item of deduped) { + uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); + } + const appended: IAICustomizationListItem[] = []; + const disabledPromptFiles = this.promptsService.getDisabledPromptFiles(PromptsType.skill); + for (const p of builtinPaths) { + const name = p.name ?? basename(p.uri); + if (overriddenNames.has(name)) { + continue; + } + const builtinItem: ICustomizationItem = { + uri: p.uri, + type: PromptsType.skill, + name, + description: p.description, + source: AICustomizationSources.builtin, + groupKey: BUILTIN_STORAGE, + enabled: !disabledPromptFiles.has(p.uri), + extensionId: undefined, + pluginUri: undefined, + userInvocable: true, + }; + appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); + } + + return [...deduped, ...appended]; + } + private async addSkillDescriptionFallbacks(items: readonly ICustomizationItem[]): Promise { const descriptionsByUri = new Map(); const skills = await this.promptsService.findAgentSkills(CancellationToken.None); @@ -471,13 +455,9 @@ export class PureItemProviderItemSource extends Disposable implements IAICustomi readonly sessionResource: URI, private readonly itemProvider: ICustomizationItemProvider, private readonly itemNormalizer: AICustomizationItemNormalizer, - private readonly promptsService: IPromptsService, - private readonly workspaceService: IAICustomizationWorkspaceService, ) { super(); - // Built-in skills are merged in from the prompts service, so their - // enable/disable state changes must refresh the list too. - this.onDidAICustomizationItemsChange = Event.any(this.itemProvider.onDidChange, this.promptsService.onDidChangeSkills); + this.onDidAICustomizationItemsChange = this.itemProvider.onDidChange; // Invalidate cache when the provider changes this._register(this.itemProvider.onDidChange(() => { @@ -505,11 +485,7 @@ export class PureItemProviderItemSource extends Disposable implements IAICustomi async fetchAICustomizationItems(promptType: PromptsType): Promise { const allItems = await this.fetchProviderItems(); - const normalized = this.itemNormalizer.normalizeItems(allItems, promptType); - if (promptType === PromptsType.skill) { - return mergeBuiltinSkills(normalized, promptType, this.promptsService, this.workspaceService, this.itemNormalizer); - } - return normalized; + return this.itemNormalizer.normalizeItems(allItems, promptType); } async fetchSourceFolders(promptType: PromptsType): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index 39be8a564aa34..4e55a61d84282 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -257,7 +257,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz this.logService.warn(`Agent-host session type ${sessionType} has no item provider`); return new EmptyItemProviderItemSource(sessionResource); } - return new PureItemProviderItemSource(sessionResource, descriptor.itemProvider, this.itemNormalizer, this.promptsService, this.workspaceService); + return new PureItemProviderItemSource(sessionResource, descriptor.itemProvider, this.itemNormalizer); } else { const itemProvider = descriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider); return new ItemProviderItemSource( diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 3047046e65223..d395911febea4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -49,6 +49,8 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IAICustomizationListItem } from './aiCustomizationItemSource.js'; import { IAICustomizationItemsModel, ItemsModelSection } from './aiCustomizationItemsModel.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -615,7 +617,8 @@ export class AICustomizationListWidget extends Disposable { private listContainer!: HTMLElement; private list!: WorkbenchList; private cardContainer!: HTMLElement; - private cardScrollElement: HTMLElement | undefined; + private cardScrollable!: DomScrollableElement; + private cardScrollableNode!: HTMLElement; private firstCardFocusElement: HTMLElement | undefined; private readonly cardRowsByUri = new Map(); private readonly cardRowsById = new Map(); @@ -781,7 +784,23 @@ export class AICustomizationListWidget extends Disposable { this._register(this.addButton.onDidClick(() => this.executePrimaryCreateAction())); this.cardContainer = DOM.append(this.element, $('.plugin-card-container.customization-card-container')); - this.cardContainer.style.display = 'none'; + this.cardScrollable = this._register(new DomScrollableElement(this.cardContainer, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: false, + })); + this._register(DOM.addDisposableListener(this.cardContainer, DOM.EventType.SCROLL, () => { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollTop }); + })); + this.cardScrollableNode = this.cardScrollable.getDomNode(); + this.cardScrollableNode.classList.add('plugin-card-scrollable'); + this.cardScrollableNode.style.display = 'none'; + this.element.appendChild(this.cardScrollableNode); + const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( + 'AICustomizationListWidget.cardScrollable', + () => this.cardScrollable.scanDomNode(), + )); + this._register(cardResizeObserver.observe(this.cardScrollableNode)); // List container this.listContainer = DOM.append(this.element, $('.list-container')); @@ -1572,10 +1591,9 @@ export class AICustomizationListWidget extends Disposable { this.cardRowsByUri.clear(); this.cardRowsById.clear(); this.cardMenuButtonsById.clear(); - this.cardScrollElement = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.updateEmptyState(); return; } @@ -1594,8 +1612,8 @@ export class AICustomizationListWidget extends Disposable { DOM.clearNode(this.cardContainer); this.listContainer.style.display = 'none'; this.emptyStateContainer.style.display = 'none'; - this.cardContainer.style.display = ''; - const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll.customization-card-scroll')); + this.cardScrollableNode.style.display = ''; + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content.customization-card-scroll')); for (const group of visibleGroups) { const section = DOM.append(content, $('.plugin-card-section.customization-card-section')); @@ -1628,6 +1646,7 @@ export class AICustomizationListWidget extends Disposable { } cardList.finalize(); } + this.cardScrollable.scanDomNode(); if (shouldRestoreFocus) { DOM.getWindow(this.element).requestAnimationFrame(() => { (this.cardMenuButtonsById.get(focusItemId ?? '') ?? this.cardRowsById.get(focusItemId ?? '') ?? this.firstCardFocusElement)?.focus(); @@ -1884,7 +1903,7 @@ export class AICustomizationListWidget extends Disposable { private updateEmptyState(): void { const hasItems = this.displayEntries.length > 0; if (!hasItems) { - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.emptyStateContainer.style.display = 'flex'; this.listContainer.style.display = 'none'; @@ -1901,7 +1920,7 @@ export class AICustomizationListWidget extends Disposable { } else { this.emptyStateContainer.style.display = 'none'; this.listContainer.style.display = this.usesCardLayout() ? 'none' : ''; - this.cardContainer.style.display = this.usesCardLayout() ? '' : 'none'; + this.cardScrollableNode.style.display = this.usesCardLayout() ? '' : 'none'; } } @@ -1976,9 +1995,7 @@ export class AICustomizationListWidget extends Disposable { */ revealLastItem(): void { if (this.usesCardLayout()) { - if (this.cardScrollElement) { - this.cardScrollElement.scrollTop = this.cardScrollElement.scrollHeight; - } + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); return; } if (this.displayEntries.length > 0) { @@ -2049,9 +2066,11 @@ export class AICustomizationListWidget extends Disposable { const availableHeight = this.element.clientHeight || height; const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); - this.cardContainer.style.height = `${listHeight}px`; + this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; - if (!this.usesCardLayout()) { + if (this.usesCardLayout()) { + this.cardScrollable.scanDomNode(); + } else { this.list.layout(listHeight, width); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 033974e44d11d..e5874fbaf1428 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -358,6 +358,7 @@ export class AICustomizationManagementEditor extends EditorPane { // Embedded MCP server detail view private mcpDetailContainer: HTMLElement | undefined; private embeddedMcpDetail: EmbeddedMcpServerDetail | undefined; + private mcpDetailBackButton: HTMLButtonElement | undefined; private readonly mcpDetailDisposables = this._register(new DisposableStore()); // Embedded plugin detail view @@ -3254,7 +3255,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.embeddedMcpDetail = this.editorDisposables.add(this.instantiationService.createInstance(EmbeddedMcpServerDetail, detailBody)); // Back button rendered into the detail's leading slot - const backButton = DOM.append(this.embeddedMcpDetail.leadingSlot, $('button.editor-back-button')); + const backButton = DOM.append(this.embeddedMcpDetail.leadingSlot, $('button.editor-back-button')); + this.mcpDetailBackButton = backButton; backButton.setAttribute('type', 'button'); backButton.setAttribute('aria-label', localize('backToMcpList', "Back to MCP servers")); this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), backButton, localize('backToMcpListTooltip', "Back to MCP servers"))); @@ -3282,7 +3284,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.dimension) { this.layout(this.dimension); } - this.embeddedMcpDetail.focus(); + this.mcpDetailBackButton?.focus(); } private goBackFromMcpDetail(): void { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts index 323dd2b71dd7d..1b89cc9dbe00c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts @@ -18,7 +18,7 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; -import { IMcpWorkbenchService, IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, McpServerInstallState } from '../../../mcp/common/mcpTypes.js'; const $ = DOM.$; @@ -26,6 +26,7 @@ export interface IMcpServerDetailInput { readonly id: string; readonly name: string; readonly label: string; + readonly installState: McpServerInstallState; readonly config?: IMcpServerConfiguration; readonly source?: { readonly uri: URI; @@ -38,6 +39,7 @@ export function createWorkbenchMcpServerDetailInput(server: IWorkbenchMcpServer) id: server.id, name: server.name, label: server.label, + installState: server.installState, config: server.config, source: server.local?.mcpResource ? { uri: server.local.mcpResource } : undefined, }; @@ -151,7 +153,9 @@ export class EmbeddedMcpServerDetail extends Disposable { this.nameEl.textContent = server.label || server.name; this.pathEl.textContent = server.source ? basename(server.source.uri) : 'mcp.json'; - if (server.config) { + if (server.installState !== McpServerInstallState.Installed) { + this.setDefinition(undefined, localize('mcpDefinitionAvailableAfterInstall', "Details are available after install when the MCP server can be inspected locally.")); + } else if (server.config) { this.setDefinition(`${JSON.stringify({ servers: { [server.name]: server.config } }, null, '\t')}\n`); } else if (server.source) { this.setDefinition(undefined, localize('mcpDefinitionLoading', "Loading MCP server definition...")); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 2910ce60e8403..5a5ae3ab13eda 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -17,6 +17,7 @@ import { defaultButtonStyles, defaultInputBoxStyles, getButtonStyles } from '../ import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { IMcpGalleryManifestService } from '../../../../../platform/mcp/common/mcpGalleryManifest.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerDefinition, McpServerInstallState, IMcpService, IMcpServer, McpServerTransportType } from '../../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../mcp/common/discovery/pluginMcpDiscovery.js'; @@ -54,6 +55,8 @@ import { Range } from '../../../../../editor/common/core/range.js'; import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { createWorkbenchMcpServerDetailInput, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; const $ = DOM.$; @@ -1011,6 +1014,7 @@ function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry): IMcpSer id: getMcpRowKey(entry), name: getMcpEntryLabel(entry), label: getMcpEntryLabel(entry), + installState: McpServerInstallState.Installed, config: localDefinition ? getMcpServerConfiguration(localDefinition) : undefined, source: localSource ?? activeSessionSource, }; @@ -1060,6 +1064,8 @@ export class McpListWidget extends Disposable { private searchAndButtonContainer!: HTMLElement; private searchInput!: InputBox; private cardContainer!: HTMLElement; + private cardScrollable!: DomScrollableElement; + private cardScrollableNode!: HTMLElement; private emptyContainer!: HTMLElement; private emptyText!: HTMLElement; private emptySubtext!: HTMLElement; @@ -1082,7 +1088,6 @@ export class McpListWidget extends Disposable { private visible = false; private mcpAccessEnabled = false; private firstCardFocusElement: HTMLElement | undefined; - private cardScrollElement: HTMLElement | undefined; private availableSection: HTMLElement | undefined; private narrowLayout = false; private wideLayout = false; @@ -1112,6 +1117,7 @@ export class McpListWidget extends Disposable { @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @INotificationService private readonly notificationService: INotificationService, + @IMcpGalleryManifestService mcpGalleryManifestService: IMcpGalleryManifestService, ) { super(); this.element = $('.mcp-list-widget.plugin-list-widget'); @@ -1123,6 +1129,20 @@ export class McpListWidget extends Disposable { )); this._register(resizeObserver.observe(this.element)); this.updateAccessState(); + this._register(mcpGalleryManifestService.onDidChangeMcpGalleryManifest(() => { + this.galleryCts?.dispose(true); + this.galleryCts = undefined; + this.gallerySnapshotServers = []; + this.galleryServers = []; + this.gallerySnapshotFailed = false; + this.gallerySnapshotLoading = false; + if (this.searchQuery.trim()) { + void this.queryMcpSearch(); + } else { + void this.refresh(); + } + })); + void mcpGalleryManifestService.getMcpGalleryManifest(); void this.refresh(); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(mcpAccessConfig)) { @@ -1234,8 +1254,24 @@ export class McpListWidget extends Disposable { disabledText.textContent = localize('mcpAccessDisabledTitle', "MCP servers are disabled"); this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); - this.cardContainer = DOM.append(this.element, $('.plugin-card-container')); - this.cardContainer.style.display = 'none'; + this.cardContainer = $('.plugin-card-container'); + this.cardScrollable = this._register(new DomScrollableElement(this.cardContainer, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: false, + })); + this._register(DOM.addDisposableListener(this.cardContainer, DOM.EventType.SCROLL, () => { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollTop }); + })); + this.cardScrollableNode = this.cardScrollable.getDomNode(); + this.cardScrollableNode.classList.add('plugin-card-scrollable'); + this.cardScrollableNode.style.display = 'none'; + this.element.appendChild(this.cardScrollableNode); + const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( + 'McpListWidget.cardScrollable', + () => this.cardScrollable.scanDomNode(), + )); + this._register(cardResizeObserver.observe(this.cardScrollableNode)); // Listen to MCP service changes this._register(this.mcpWorkbenchService.onChange(() => { @@ -1403,16 +1439,27 @@ export class McpListWidget extends Disposable { private showCardSurface(): void { this.emptyContainer.style.display = 'none'; - this.cardContainer.style.display = ''; + this.cardScrollableNode.style.display = ''; } private showEmptySurface(message: string, detail: string): void { - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.emptyContainer.style.display = 'flex'; this.emptyText.textContent = message; this.emptySubtext.textContent = detail; } + private createCardScrollContent(...classNames: string[]): HTMLElement { + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); + content.classList.add(...classNames); + const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( + 'McpListWidget.cardScrollContent', + () => this.cardScrollable.scanDomNode(), + )); + this.cardDisposables.add(resizeObserver.observe(content)); + return content; + } + private addSurfaceActivation(surface: HTMLElement, label: string, callback: () => void, ...classNames: string[]): HTMLButtonElement { const primaryAction = createCustomizationCardPrimaryAction(surface, label, ...classNames); this.firstCardFocusElement ??= primaryAction; @@ -1454,7 +1501,7 @@ export class McpListWidget extends Disposable { DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const content = this.createCardScrollContent(); this.renderFeaturedServers(content); const installedList = this.renderCardSection( @@ -1821,7 +1868,7 @@ export class McpListWidget extends Disposable { this.availableSection = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-search-results')); + const content = this.createCardScrollContent('plugin-search-results'); if (this.installedEntries.length > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-mcp-servers-section', this.installedEntries.length); installedList.classList.add('plugin-inventory-list'); @@ -1986,7 +2033,8 @@ export class McpListWidget extends Disposable { this.lastHeaderHeight = headerHeight; const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); - this.cardContainer.style.height = `${listHeight}px`; + this.cardScrollableNode.style.height = `${listHeight}px`; + this.cardScrollable.scanDomNode(); } /** @@ -2000,16 +2048,14 @@ export class McpListWidget extends Disposable { * Scrolls the list so the last item is visible. */ revealLastItem(): void { - if (this.cardScrollElement) { - this.cardScrollElement.scrollTop = this.cardScrollElement.scrollHeight; - } + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); } /** * Focuses the list. */ focus(): void { - if (this.cardContainer.style.display !== 'none') { + if (this.cardScrollableNode.style.display !== 'none') { this.firstCardFocusElement?.focus(); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 09bb1538ff537..8b5104713385d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -448,7 +448,7 @@ .ai-customization-list-widget .list-container { flex: 1; min-height: 0; - overflow: auto; + overflow: hidden; } .ai-customization-list-widget .list-empty-message { @@ -1336,6 +1336,9 @@ per-word capitalization does not survive translation. */ position: relative; flex: 1; min-height: 0; + width: 100%; + max-width: calc(840px + var(--vscode-spacing-size160)); + margin: 0 auto; } /* The scrolled element: bound it to the wrapper so clientHeight < scrollHeight when content overflows. */ @@ -1346,7 +1349,7 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; gap: var(--vscode-spacing-size240); - padding: 0 max(var(--vscode-spacing-size20), calc((100% - 840px) / 2)) var(--vscode-spacing-size200); + padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; box-sizing: border-box; } @@ -1694,10 +1697,27 @@ per-word capitalization does not survive translation. */ } .ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-empty { - margin: var(--vscode-spacing-size80) var(--vscode-spacing-size160) var(--vscode-spacing-size160); - padding: var(--vscode-spacing-size160); - border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); - border-radius: var(--vscode-cornerRadius-medium); + margin: var(--vscode-spacing-size80) calc(var(--vscode-spacing-size160) - var(--vscode-spacing-size40)) var(--vscode-spacing-size160); + padding-inline: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-small); +} + +.ai-customization-management-editor .embedded-mcp-detail .embedded-detail-leading-slot:empty { + display: none; +} + +.ai-customization-management-editor .mcp-detail-container .mcp-detail-header { + gap: var(--vscode-spacing-size80); + padding-inline: var(--vscode-spacing-size160); +} + +.ai-customization-management-editor .mcp-detail-container .editor-back-button { + width: var(--vscode-spacing-size280); + height: var(--vscode-spacing-size280); +} + +.ai-customization-management-editor .mcp-detail-container .mcp-detail-definition-empty { + margin-inline-start: calc(var(--vscode-spacing-size160) + var(--vscode-spacing-size280) + var(--vscode-spacing-size80) - var(--vscode-spacing-size40)); } /* The tools detail lists many short tool descriptions, so let it use the full editor width. */ @@ -2110,7 +2130,7 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-empty { color: var(--vscode-descriptionForeground); font-size: var(--vscode-fontSize-body1); - padding: 8px 0; + padding-block: var(--vscode-spacing-size80); } /* Included tools list (tool extension detail) */ @@ -2752,6 +2772,20 @@ per-word capitalization does not survive translation. */ overflow: hidden; } +.plugin-list-widget .plugin-card-scrollable { + position: relative; + flex: 1; + min-height: 0; + width: 100%; + max-width: calc(840px + var(--vscode-spacing-size160)); + margin: 0 auto; +} + +.plugin-list-widget .plugin-card-scrollable > .plugin-card-container { + position: absolute; + inset: 0; +} + .plugin-list-widget .plugin-card-scroll { height: 100%; overflow: auto; @@ -2759,6 +2793,12 @@ per-word capitalization does not survive translation. */ box-sizing: border-box; } +.plugin-list-widget .plugin-card-scroll-content { + height: auto; + overflow: visible; + padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; +} + .plugin-list-widget .plugin-marketplace-back-container { flex-shrink: 0; display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 69a83b62e2650..0885ce840f8b4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -47,6 +47,8 @@ import { getErrorMessage } from '../../../../../base/common/errors.js'; import { getPluginInclusionLabel } from './aiCustomizationPresentation.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; const $ = DOM.$; @@ -634,6 +636,8 @@ export class PluginListWidget extends Disposable { private searchAndButtonContainer!: HTMLElement; private searchInput!: InputBox; private cardContainer!: HTMLElement; + private cardScrollable!: DomScrollableElement; + private cardScrollableNode!: HTMLElement; private listContainer!: HTMLElement; private list!: WorkbenchList; private emptyContainer!: HTMLElement; @@ -858,8 +862,24 @@ export class PluginListWidget extends Disposable { disabledText.textContent = localize('pluginsDisabledTitle', "Plugins are disabled"); this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); - this.cardContainer = DOM.append(this.element, $('.plugin-card-container')); - this.cardContainer.style.display = 'none'; + this.cardContainer = $('.plugin-card-container'); + this.cardScrollable = this._register(new DomScrollableElement(this.cardContainer, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: false, + })); + this._register(DOM.addDisposableListener(this.cardContainer, DOM.EventType.SCROLL, () => { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollTop }); + })); + this.cardScrollableNode = this.cardScrollable.getDomNode(); + this.cardScrollableNode.classList.add('plugin-card-scrollable'); + this.cardScrollableNode.style.display = 'none'; + this.element.appendChild(this.cardScrollableNode); + const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( + 'PluginListWidget.cardScrollable', + () => this.cardScrollable.scanDomNode(), + )); + this._register(cardResizeObserver.observe(this.cardScrollableNode)); // List container this.listContainer = DOM.append(this.element, $('.mcp-list-container')); @@ -1180,15 +1200,26 @@ export class PluginListWidget extends Disposable { private showCardSurface(): void { this.emptyContainer.style.display = 'none'; this.listContainer.style.display = 'none'; - this.cardContainer.style.display = ''; + this.cardScrollableNode.style.display = ''; } private showEmptySurface(): void { - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.listContainer.style.display = 'none'; this.emptyContainer.style.display = 'flex'; } + private createCardScrollContent(...classNames: string[]): HTMLElement { + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); + content.classList.add(...classNames); + const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( + 'PluginListWidget.cardScrollContent', + () => this.cardScrollable.scanDomNode(), + )); + this.cardDisposables.add(resizeObserver.observe(content)); + return content; + } + private addSurfaceActivation(surface: HTMLElement, label: string, callback: () => void, ...classNames: string[]): HTMLButtonElement { const primaryAction = createCustomizationCardPrimaryAction(surface, label, ...classNames); this.rememberCardFocusElement(primaryAction); @@ -1231,7 +1262,7 @@ export class PluginListWidget extends Disposable { DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const content = this.createCardScrollContent(); const installedPlugins = this.installedItems; this.renderDiscoverySnapshot(content); @@ -1587,7 +1618,7 @@ export class PluginListWidget extends Disposable { } this.showCardSurface(); - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const content = this.createCardScrollContent(); const recommendedKeys = this.pluginMarketplaceService.recommendedPlugins.get(); const recommended = marketplaceItems.filter(item => recommendedKeys.has(getMarketplaceRecommendationKey(item))); const allPlugins = marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))); @@ -1905,7 +1936,7 @@ export class PluginListWidget extends Disposable { this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-search-results')); + const content = this.createCardScrollContent('plugin-search-results'); if (installedCount > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-plugins-section', installedCount); installedList.classList.add('plugin-inventory-list'); @@ -2053,9 +2084,10 @@ export class PluginListWidget extends Disposable { const backHeight = this.marketplaceBackContainer.offsetHeight; const listHeight = Math.max(0, height - searchBarHeight - headerHeight - backHeight); - this.cardContainer.style.height = `${listHeight}px`; + this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; this.list.layout(listHeight, width); + this.cardScrollable.scanDomNode(); } focusSearch(): void { @@ -2063,13 +2095,17 @@ export class PluginListWidget extends Disposable { } revealLastItem(): void { + if (this.cardScrollableNode.style.display !== 'none') { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + return; + } if (this.list.length > 0) { this.list.reveal(this.list.length - 1); } } focus(): void { - if (this.cardContainer.style.display !== 'none') { + if (this.cardScrollableNode.style.display !== 'none') { this.firstCardFocusElement?.focus(); } else if (this.list.length > 0) { this.list.domFocus(); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 5aa9aa64424d8..aed935b6aff2c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1004,6 +1004,12 @@ configurationRegistry.registerConfiguration({ default: true, markdownDescription: nls.localize('chat.progressBorder.enabled', "Show an animated gradient border around the chat input while the agent is working or thinking. Has no effect when reduced motion is enabled."), }, + [ChatConfiguration.SessionStateIndicatorEnabled]: { + type: 'boolean', + default: false, + description: nls.localize('chat.experimental.sessionStateIndicator.enabled', "Enable state indicators around chat editor sessions."), + tags: ['experimental'], + }, [ChatConfiguration.NotifyWindowOnResponseReceived]: { type: 'string', enum: ['off', 'windowNotFocused', 'always'], diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index f1578aa3b9ab0..60300b7ad8dd6 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -326,6 +326,9 @@ export interface IChatWidgetViewOptions { */ isSessionsWindow?: boolean; + /** Whether this host supports the experimental session state indicator. Defaults to false. */ + enableSessionStateIndicator?: boolean; + /** Enables the transcript Find widget (`Ctrl/Cmd+F`) for this chat widget. Off by default. */ enableFind?: boolean; diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 2640406797dde..c1ea8aa7a9c5d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatSetup.css'; -import { $ } from '../../../../../base/browser/dom.js'; +import { $, getWindow, releaseReservedWindowForExternalOpen, reserveWindowForExternalOpen } from '../../../../../base/browser/dom.js'; +import { isSafari, isMobileStandalone } from '../../../../../base/browser/browser.js'; +import { IButton } from '../../../../../base/browser/ui/button/button.js'; import { Dialog, DialogContentsAlignment } from '../../../../../base/browser/ui/dialog/dialog.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -93,6 +95,24 @@ export interface IChatSetupDialogOptions { readonly renderFooter?: (container: HTMLElement) => IDisposable | undefined; } +/** + * Whether this strategy sends the user to a provider's sign-in page. `DefaultSetup` + * is excluded: for an already signed-in user it installs and signs up with no + * browser round trip. + */ +function entersProviderAuthentication(strategy: ChatSetupStrategy): boolean { + switch (strategy) { + case ChatSetupStrategy.SetupWithEnterpriseProvider: + case ChatSetupStrategy.SetupWithoutEnterpriseProvider: + case ChatSetupStrategy.SetupWithGoogleProvider: + case ChatSetupStrategy.SetupWithAppleProvider: + case ChatSetupStrategy.SetupWithMicrosoftProvider: + return true; + default: + return false; + } +} + export class ChatSetupDialog extends Disposable { private readonly dialog: Dialog; @@ -135,7 +155,26 @@ export class ChatSetupDialog extends Disposable { }, buttonOptions: options.buttons.map(button => { const classes = button.classes; - return classes ? { styleButton: control => control.element.classList.add(...classes) } : undefined; + // Claim the sign-in window while the click's activation is still live; + // see `reserveWindowForExternalOpen`. Only installed mobile apps (fatal, + // no tab to fall back to) and Safari (recoverable via "Retry") need this. + const opensBrowser = (isMobileStandalone() || isSafari) && entersProviderAuthentication(button.strategy); + if (!classes && !opensBrowser) { + return undefined; + } + return { + styleButton: (control: IButton) => { + if (classes?.length) { + control.element.classList.add(...classes); + } + if (opensBrowser) { + this._register(control.onDidClick(() => reserveWindowForExternalOpen( + getWindow(control.element), + localize('signingInPlaceholder', "Signing in…") + ))); + } + } + }; }) }, keybindingService, layoutService, hostService) )); @@ -372,6 +411,10 @@ export class ChatSetup { } } finally { setupCancellation.dispose(); + // no browser window was opened, so the reservation is still blank + releaseReservedWindowForExternalOpen( + localize('signInDidNotComplete', "Sign-in did not complete. You can close this window.") + ); } if (success) { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index a42898544bf6c..eb07cec597ce8 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -16,8 +16,9 @@ import { IContextKey, IContextKeyService } from '../../../../../platform/context import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { IProgress, IProgressService, IProgressStep, Progress, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; -import { DeferredPromise, raceCancellation } from '../../../../../base/common/async.js'; +import { DeferredPromise, raceCancellation, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../base/common/errors.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { localize } from '../../../../../nls.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; @@ -158,6 +159,8 @@ export function isDictationEntitled(entitlement: ChatEntitlement, isInternal: bo const MAI_CONNECT_TIMEOUT_MS = 8000; /** How long to wait after `ptt_end` for the backend's final transcript before returning what we have. */ const MAI_FINAL_TIMEOUT_MS = 4000; +/** How long to wait for the on-device backend to finish before returning its streamed transcript. */ +const NEMO_FINAL_TIMEOUT_MS = 8000; /** How long to wait for the backend to acknowledge the opened session before streaming audio anyway. */ const MAI_SESSION_INIT_TIMEOUT_MS = 4000; @@ -172,6 +175,8 @@ type SpeechToTextSessionEvent = { timeToFirstTranscriptMs: number; finalizeMs: number; errorCode: string; + errorName: string; + closeCode: number; cleanupModel: DictationCleanupModel; }; type SpeechToTextSessionClassification = { @@ -187,6 +192,8 @@ type SpeechToTextSessionClassification = { timeToFirstTranscriptMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the first streamed audio chunk to the first transcript update; the backend transcription latency (excludes mic acquisition and model download). -1 when no transcript arrived.' }; finalizeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the user stopping recording until the final transcript resolved; the post-stop wait. -1 when not applicable.' }; errorCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Short error identifier when the session failed, else empty.' }; + errorName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Error type reported by the platform when the session failed, else empty.' }; + closeCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Voice websocket close code when a cloud dictation session failed, else 0.' }; cleanupModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language model used to attempt dictation cleanup, or none when no model request was made.' }; }; @@ -428,9 +435,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _entitlementCheckScheduled = false; private _startGeneration = 0; private _startInProgress: number | undefined; + private _hasGitHubSession = false; + private _githubSessionGeneration = 0; get isBusy(): boolean { - return this._state !== ChatSpeechToTextState.Idle || this._pendingStart !== undefined || this._pendingStop !== undefined; + return this._state !== ChatSpeechToTextState.Idle || this._pendingStart !== undefined || this._pendingStop !== undefined || this._startInProgress !== undefined; } get currentSurface(): ChatDictationSurface { @@ -450,6 +459,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _sessionGeneration = 0; private _pendingStart: Promise | undefined; private _pendingStop: Promise | undefined; + private _pendingLocalTeardown: Promise | undefined; /** Drains the capture worklet's trailing buffer; see {@link IPcmCaptureNode.flush}. */ private _flushCapture: (() => Promise) | undefined; @@ -467,6 +477,8 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _maiRevision = -1; /** Whether this dictation established the shared voice connection (and may thus tear it down). */ private _maiOwnsConnection = false; + /** Whether the active MAI startup reached a connected voice socket. */ + private _maiConnected = false; /** Resolves when the backend emits the final transcript after `ptt_end`. */ private _maiFinalTranscript: DeferredPromise | undefined; @@ -479,9 +491,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return false; } if (backend === 'mai') { - // The cloud backend needs a configured voice websocket endpoint; - // GitHub sign-in and connectivity are validated when a session starts. - return !!this._voiceWsUrl(); + return !!this._voiceWsUrl() && this._hasGitHubSession; } // On-device transcription needs no configuration — the model downloads // on first use. It is only unavailable where the platform lacks native @@ -509,6 +519,8 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _sessionSegments = 0; private _sessionPartialUpdates = 0; private _sessionErrorCode = ''; + private _sessionErrorName = ''; + private _sessionCloseCode = 0; private _sessionSurface: ChatDictationSurface = 'chat'; /** Timestamp of the first streamed audio chunk, to measure transcription latency. */ private _firstAudioMs = 0; @@ -551,6 +563,24 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._configuredContextKey = ChatContextKeys.speechToTextConfigured.bindTo(contextKeyService); this._preparingContextKey = ChatContextKeys.speechToTextPreparing.bindTo(contextKeyService); this._updateConfiguredContextKey(); + void this._refreshGitHubSession(); + this._register(this._authenticationService.onDidChangeSessions(e => { + if (e.providerId === 'github') { + void this._refreshGitHubSession(); + } + })); + this._register(this._authenticationService.onDidRegisterAuthenticationProvider(e => { + if (e.id === 'github') { + void this._refreshGitHubSession(); + } + })); + this._register(this._authenticationService.onDidUnregisterAuthenticationProvider(e => { + if (e.id === 'github') { + this._githubSessionGeneration++; + this._hasGitHubSession = false; + this._updateConfiguredContextKey(); + } + })); this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ENABLED_SETTING) || e.affectsConfiguration(DICTATION_MODEL_SETTING)) { this._updateConfiguredContextKey(); @@ -633,6 +663,23 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._configuredContextKey.set(this.isConfigured); } + private async _refreshGitHubSession(): Promise { + const generation = ++this._githubSessionGeneration; + try { + const sessions = await this._authenticationService.getSessions('github', [], { silent: true }); + if (generation !== this._githubSessionGeneration || this._store.isDisposed) { + return; + } + const hasGitHubSession = sessions.length > 0; + if (this._hasGitHubSession !== hasGitHubSession) { + this._hasGitHubSession = hasGitHubSession; + this._updateConfiguredContextKey(); + } + } catch (err) { + this._logService.warn('[chat-stt] could not refresh GitHub session state for cloud dictation', err); + } + } + private _setPreparingModel(preparing: boolean): void { if (this._isPreparingModel === preparing) { return; @@ -683,6 +730,8 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo timeToFirstTranscriptMs, finalizeMs: this._finalizeMs, errorCode: this._sessionErrorCode, + errorName: this._sessionErrorName, + closeCode: this._sessionCloseCode, cleanupModel: this._sessionCleanupModel, }); this._sessionStartMs = 0; @@ -779,11 +828,19 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } private async _startEntitled(window: Window & typeof globalThis, surface: ChatDictationSurface, backend: DictationBackend, generation: number, startGeneration: number): Promise { + if (backend === 'nemo') { + await this._pendingLocalTeardown; + if (!this._isCurrentStart(generation, startGeneration, backend)) { + return; + } + } const captureWindow = getMediaCaptureWindow(window); this._sessionStartMs = Date.now(); this._sessionSegments = 0; this._sessionPartialUpdates = 0; this._sessionErrorCode = ''; + this._sessionErrorName = ''; + this._sessionCloseCode = 0; this._sessionSurface = surface; this._firstAudioMs = 0; this._firstTranscriptMs = 0; @@ -804,6 +861,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return; } this._sessionErrorCode = this._sessionErrorCode || 'microphone'; + this._sessionErrorName = err instanceof Error ? err.name : ''; this._logSessionTelemetry('error'); this._logService.error('[chat-stt] microphone acquisition failed', err); this._notificationService.error(localize('chatStt.micError', "Could not access the microphone for speech-to-text: {0}", toErrorMessage(err))); @@ -915,6 +973,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo */ private async _startMaiSession(window: Window & typeof globalThis, generation: number): Promise { if (this._voiceClientService.isConnected) { + this._sessionErrorCode = this._sessionErrorCode || 'connect.busy'; throw new Error(localize('chatStt.maiBusy', "Cloud dictation is unavailable while Voice Mode is connected.")); } const authToken = await this._getGitHubToken(); @@ -922,6 +981,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return; } if (!authToken) { + this._sessionErrorCode = this._sessionErrorCode || 'connect.noauth'; throw new Error(localize('chatStt.maiSignIn', "Sign in to GitHub to use cloud dictation.")); } @@ -931,8 +991,12 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // A terminal close (e.g. code 4008 when another window takes over the // single voice session) stops reconnection; without this the mic would // stay open in Recording while audio is silently dropped. - this._maiSessionDisposables.add(this._voiceClientService.onFatalDisconnect(() => - this._failMaiSession(localize('chatStt.maiDisconnected', "Cloud dictation was disconnected.")))); + this._maiSessionDisposables.add(this._voiceClientService.onFatalDisconnect(e => { + if (this._maiConnected || this._state !== ChatSpeechToTextState.Idle) { + this._sessionCloseCode = e.code; + this._failMaiSession(localize('chatStt.maiDisconnected', "Cloud dictation was disconnected.")); + } + })); this._maiSessionDisposables.add(this._voiceClientService.onError(msg => this._logService.warn(`[chat-stt] voice service error during dictation: ${msg}`))); @@ -1029,9 +1093,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo * tear down the mic/session, and surface an actionable message. */ private _failMaiSession(message: string): void { - if (this._activeBackend !== 'mai' || this._state === ChatSpeechToTextState.Idle) { + if (this._activeBackend !== 'mai' || (this._state === ChatSpeechToTextState.Idle && !this._maiConnected)) { return; } + this._sessionGeneration++; + this._startGeneration++; this._sessionErrorCode = this._sessionErrorCode || 'disconnect'; this._logSessionTelemetry('error'); this._maiFinalTranscript?.complete(); @@ -1045,7 +1111,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private async _getGitHubToken(): Promise { try { const sessions = await this._authenticationService.getSessions('github'); - return sessions[0]?.accessToken; + if (sessions[0]) { + return sessions[0].accessToken; + } + const session = await this._authenticationService.createSession('github', []); + return session.accessToken; } catch (err) { this._logService.warn('[chat-stt] could not resolve a GitHub session for cloud dictation', err); return undefined; @@ -1055,22 +1125,47 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo /** Wait for the voice websocket to report connected, or reject on timeout. */ private async _awaitVoiceConnected(): Promise { if (this._voiceClientService.isConnected) { + this._maiConnected = true; return; } await new Promise((resolve, reject) => { const store = new DisposableStore(); this._maiSessionDisposables.add(store); - store.add(toDisposable(resolve)); - const timer = setTimeout(() => { - reject(new Error('Timed out connecting to the voice service.')); + let settled = false; + const settle = (error?: Error) => { + if (settled) { + return; + } + settled = true; store.dispose(); + if (error) { + reject(error); + } else { + resolve(); + } + }; + store.add(toDisposable(() => { + if (!settled) { + settled = true; + reject(new CancellationError()); + } + })); + const timer = setTimeout(() => { + this._sessionErrorCode = this._sessionErrorCode || 'connect.timeout'; + settle(new Error(localize('chatStt.maiConnectTimeout', "Timed out connecting to the voice service."))); }, MAI_CONNECT_TIMEOUT_MS); store.add(toDisposable(() => clearTimeout(timer))); store.add(this._voiceClientService.onDidChangeConnectionState(connected => { if (connected) { - store.dispose(); + this._maiConnected = true; + settle(); } })); + store.add(this._voiceClientService.onFatalDisconnect(e => { + this._sessionCloseCode = e.code; + this._sessionErrorCode = this._sessionErrorCode || `connect.rejected.${e.code}`; + settle(new Error(localize('chatStt.maiConnectRejected', "The voice service rejected the connection (code {0}).", e.code))); + })); }); } @@ -1560,7 +1655,24 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo ]); return this._transcript; } - return this._localTranscription.stop(); + const stop = this._localTranscription.stop(); + const finalText = await raceTimeout(stop, NEMO_FINAL_TIMEOUT_MS); + if (finalText !== undefined) { + return finalText; + } + this._logService.warn(`[chat-stt] on-device final transcription timed out after ${NEMO_FINAL_TIMEOUT_MS}ms; using streamed transcript`); + const cancel = this._localTranscription.cancel(); + const teardown = Promise.all([ + stop.catch(error => this._logService.warn('[chat-stt] on-device final transcription failed after timing out', error)), + cancel.catch(error => this._logService.warn('[chat-stt] failed to cancel on-device transcription after finalization timeout', error)), + ]).then(() => undefined); + this._pendingLocalTeardown = teardown; + void teardown.then(() => { + if (this._pendingLocalTeardown === teardown) { + this._pendingLocalTeardown = undefined; + } + }); + return this._transcript; } async cancel(): Promise { @@ -1723,6 +1835,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._maiFinalTranscript = undefined; this._maiTurnId = ''; this._maiRevision = -1; + this._maiConnected = false; // Release the shared voice connection only if this dictation owns it, so // tearing down never disconnects a session Voice Mode established. if (this._activeBackend === 'mai' && this._maiOwnsConnection) { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts index 3e26f1d7c3f3a..e4ae4ef40d3f7 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/dictationSession.css'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Emitter } from '../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; @@ -25,6 +26,11 @@ import { ChatDictationSurface, ChatSpeechToTextState, IChatSpeechToTextService } const INTERIM_PROCESSING_CLASS = 'dictation-interim-processing'; const LOG_PREFIX = '[chat-stt-dictation]'; +const MAX_DICTATION_DURATION_MS = 20 * 60 * 1000; + +function isRecording(service: IChatSpeechToTextService): boolean { + return service.state === ChatSpeechToTextState.Recording; +} /** * Renders the cumulative transcript into a code editor, replacing its own @@ -439,12 +445,21 @@ export async function startDictation(service: IChatSpeechToTextService, editor: // composer is closed); cancel dictation instead of leaving the microphone // and local transcription running against a dead editor. disposables.add(editor.onDidDispose(() => cancelDictation())); - setActiveDictation({ service, editor, inserter, disposables, logService, surface }); + const activeDictation = { service, editor, inserter, disposables, logService, surface }; + setActiveDictation(activeDictation); try { await service.start(window, surface); + if (_active === activeDictation && isRecording(service)) { + const durationLimit = window.setTimeout(() => { + logService.info(`${LOG_PREFIX} stopping after maximum duration`); + status(localize('chatStt.maximumDurationReached', "Dictation stopped after 20 minutes.")); + void stopDictation(); + }, MAX_DICTATION_DURATION_MS); + disposables.add(toDisposable(() => window.clearTimeout(durationLimit))); + } } catch { // Acquisition/connection failure is surfaced by the service. - if (_active?.service === service) { + if (_active === activeDictation) { setActiveDictation(undefined); } disposables.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 8f26164883139..2719423a5a0ae 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -66,7 +66,9 @@ export type VoiceState = 'idle' | 'listening' | 'processing' | 'speaking' | 'err export function isVoiceEntitled(chatEntitlementService: IChatEntitlementService): boolean { return isProUser(chatEntitlementService.entitlement) - && (chatEntitlementService.entitlement !== ChatEntitlement.Enterprise || chatEntitlementService.isInternal); + && (chatEntitlementService.isInternal + || (chatEntitlementService.entitlement !== ChatEntitlement.Business + && chatEntitlementService.entitlement !== ChatEntitlement.Enterprise)); } /** One buffered audio chunk of a deferred response. */ @@ -849,6 +851,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } }); })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('agents.voice.enabled') && !this._isVoiceModeEnabled()) { + this.disconnect(); + } + })); // Track the focused chat session so we can defer voice responses that // arrive for a session the user isn't currently looking at, and flush @@ -1050,9 +1057,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC async connect(window: Window & typeof globalThis): Promise { if (this._isConnecting.get() || this._isConnected.get()) { return; } + if (!this._isVoiceModeEnabled()) { + this.notificationService.warn(localize('voiceMode.disabled', "Voice Mode is disabled.")); + return; + } if (!isVoiceEntitled(this.chatEntitlementService)) { - this.notificationService.warn(this.chatEntitlementService.entitlement === ChatEntitlement.Enterprise - ? localize('voiceMode.enterpriseUnavailable', "Voice Mode is not available for GitHub Copilot Enterprise accounts.") + this.notificationService.warn(this.chatEntitlementService.entitlement === ChatEntitlement.Business || this.chatEntitlementService.entitlement === ChatEntitlement.Enterprise + ? localize('voiceMode.organizationUnavailable', "Voice Mode is not available for GitHub Copilot Business or Enterprise accounts.") : localize('voiceMode.requiresPaidPlan', "Voice Mode requires a paid GitHub Copilot plan.")); return; } @@ -2159,6 +2170,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._armConnectWatchdog(); } + private _isVoiceModeEnabled(): boolean { + return this.configurationService.getValue('agents.voice.enabled') === true; + } + setActiveWindow(window: Window & typeof globalThis): void { this._window = window; this._windowFocusDisposables.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts index 5586213e2d781..525b1ccb89e3d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts @@ -7,13 +7,14 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { getDefaultHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { autorun } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; -import { ILinkPresentationService } from '../../../../../../../platform/dataChannel/common/dataChannel.js'; +import { ILinkPresentation, ILinkPresentationService } from '../../../../../../../platform/dataChannel/common/dataChannel.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { IChatSessionCreatedData, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IChatCodeBlockInfo } from '../../../chat.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; +import { ChatRichLink } from '../chatRichLink.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; import '../media/chatSessionCreatedResult.css'; @@ -39,7 +40,13 @@ export class ChatSessionCreatedResultSubPart extends BaseChatToolInvocationSubPa super(toolInvocation); this.domNode = dom.$('.chat-open-session-result'); - const link = dom.append(this.domNode, dom.$('a.monaco-link', { href: this.data.openLink }, this.data.label)); + const link = dom.append(this.domNode, dom.$('a.monaco-link', { href: this.data.openLink })); + const richLink = this._register(ChatRichLink.mount(link, dom.$('span', undefined, this.data.label))); + const fallbackPresentation: ILinkPresentation = { + kind: this.data.isChat ? 'chat' : 'session', + title: this.data.fullTitle ?? this.data.label, + }; + richLink.update(fallbackPresentation); const hover = this._register(hoverService.setupManagedHover( getDefaultHoverDelegate('mouse'), link, @@ -49,18 +56,15 @@ export class ChatSessionCreatedResultSubPart extends BaseChatToolInvocationSubPa dom.EventHelper.stop(event, true); void this.openerService.open(URI.parse(this.data.openLink), { fromUserGesture: true, allowContributedOpeners: true }); })); - const resource = URI.parse(this.data.openLink); const rule = linkPresentationService.getLinkPresentationRule(resource); const watcher = rule ? linkPresentationService.createLinkPresentationWatcher(rule.id, resource) : undefined; if (watcher) { this._register(watcher); this._register(autorun(reader => { - const presentation = watcher.presentation.read(reader); - const fullTitle = presentation?.title ?? this.data.fullTitle ?? this.data.label; - const label = fullTitle.length > 60 ? `${fullTitle.slice(0, 57)}…` : fullTitle; - link.textContent = label; - hover.update(fullTitle); + const presentation = watcher.presentation.read(reader) ?? fallbackPresentation; + richLink.update(presentation); + hover.update(presentation.tooltip ?? presentation.title ?? this.data.fullTitle ?? this.data.label); })); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 1a565e315e804..2674e66f786ac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -25,6 +25,7 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, dispose, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; +import { rewriteMarkdownLinks } from '../../../../../base/common/markdownLinks.js'; import { ScrollEvent } from '../../../../../base/common/scrollable.js'; import { FileAccess, Schemas } from '../../../../../base/common/network.js'; import { clamp, formatTokenCount } from '../../../../../base/common/numbers.js'; @@ -280,6 +281,36 @@ function isResponseOutcomeTool(part: IChatRendererContent): boolean { && (part.toolSpecificData?.kind === 'sessionCreated' || part.toolSpecificData?.kind === 'generatedImage'); } +function getSessionCreatedOutcomeLink(part: IChatRendererContent): string | undefined { + return (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && part.toolSpecificData?.kind === 'sessionCreated' + ? part.toolSpecificData.openLink + : undefined; +} + +function getFinalResponseLinkTargets(content: ReadonlyArray): ReadonlySet { + const targets = new Set(); + const finalResponseStartIndex = getFinalResponseStartIndex(content); + if (finalResponseStartIndex === undefined) { + return targets; + } + + for (let index = finalResponseStartIndex; index < content.length; index++) { + const part = content[index]; + if (part.kind !== 'markdownContent') { + break; + } + rewriteMarkdownLinks(part.content.value, { + rewriteLink: token => { + if (token.type === 'link') { + targets.add(token.href); + } + return undefined; + } + }); + } + return targets; +} + export function getFinalResponseStartIndexAfterMovingResponseOutcomeTools(content: ReadonlyArray): number | undefined { const finalResponseStartIndex = getFinalResponseStartIndex(content); if (finalResponseStartIndex === undefined) { @@ -304,6 +335,15 @@ export function moveResponseOutcomeToolsAfterFinalResponse(content: ReadonlyArra if (outcomeTools.length === 0) { return [...content]; } + const responseLinkTargets = outcomeTools.some(part => getSessionCreatedOutcomeLink(part) !== undefined) + ? getFinalResponseLinkTargets(content) + : undefined; + const uniqueOutcomeTools = responseLinkTargets + ? outcomeTools.filter(part => { + const openLink = getSessionCreatedOutcomeLink(part); + return openLink === undefined || !responseLinkTargets.has(openLink); + }) + : outcomeTools; const finalResponseStartIndex = getFinalResponseStartIndexAfterMovingResponseOutcomeTools(content); if (finalResponseStartIndex === undefined) { @@ -315,7 +355,7 @@ export function moveResponseOutcomeToolsAfterFinalResponse(content: ReadonlyArra while (reordered[insertionIndex]?.kind === 'markdownContent') { insertionIndex++; } - reordered.splice(insertionIndex, 0, ...outcomeTools); + reordered.splice(insertionIndex, 0, ...uniqueOutcomeTools); return reordered; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index abe5593812e65..b979b5aaea596 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -302,6 +302,24 @@ export const chatPersistentContentVisibleClass = 'chat-persistent-content-visibl /** Carries {@link IChatWidgetViewOptions.persistentContentHeight} to `chat.css`. */ export const chatPersistentContentHeightVariable = '--vscode-chat-persistent-content-height'; +/** Computes the visual session state and whether its latest completion remains unvisited. */ +export function computeChatSessionStateIndicatorState(input: { + readonly requestNeedsInput: boolean; + readonly requestInProgress: boolean; + readonly containsFocus: boolean; + readonly requestWasActive: boolean; + readonly hasUnvisitedCompletion: boolean; +}) { + const state = input.requestNeedsInput ? 'needsInput' : input.requestInProgress ? 'inProgress' : 'idle'; + const requestActive = state !== 'idle'; + let hasUnvisitedCompletion = input.containsFocus ? false : input.hasUnvisitedCompletion; + if (!requestActive && input.requestWasActive) { + hasUnvisitedCompletion = !input.containsFocus; + } + + return { state, requestActive, hasUnvisitedCompletion }; +} + export class ChatWidget extends Disposable implements IChatWidget { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -468,6 +486,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly viewModelDisposables = this._register(new DisposableStore()); private _viewModel: ChatViewModel | undefined; + private _requestActiveForStateIndicator = false; + private _hasUnvisitedCompletion = false; private set viewModel(viewModel: ChatViewModel | undefined) { if (this._viewModel === viewModel) { @@ -478,6 +498,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewModelDisposables.clear(); this._viewModel = viewModel; + this._requestActiveForStateIndicator = false; + this._hasUnvisitedCompletion = false; if (viewModel) { this.viewModelDisposables.add(viewModel); this.logService.debug(`ChatWidget#setViewModel: have viewModel session=${viewModel.sessionResource.toString()} requests=${viewModel.model.getRequests().length}`); @@ -491,6 +513,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource }); + this.updateSessionStateIndicator(); } get viewModel() { @@ -672,6 +695,10 @@ export class ChatWidget extends Disposable implements IChatWidget { if (e.affectsConfiguration(ChatConfiguration.ProgressBorder)) { this.updateWorkingProgressBorder(); } + if (e.affectsConfiguration(ChatConfiguration.SessionStateIndicatorEnabled)) { + this.updateWorkingProgressBorder(); + this.updateSessionStateIndicator(); + } })); this._register(this.accessibilityService.onDidChangeReducedMotion(() => { @@ -947,13 +974,52 @@ export class ChatWidget extends Disposable implements IChatWidget { } const enabled = this.configurationService.getValue(ChatConfiguration.ProgressBorder) === true && !this.accessibilityService.isMotionReduced() - && !isInlineChat(this); + && !isInlineChat(this) + && !this.isSessionStateIndicatorEnabled(); const inProgress = !!this.viewModel?.model.requestInProgress.get(); const working = enabled && inProgress; inputContainer.classList.toggle('working', working); setChatInputStackInputWorking(inputContainer, working); } + private isSessionStateIndicatorEnabled(): boolean { + if (isInlineChat(this) || isQuickChat(this) || this.viewOptions.enableSessionStateIndicator !== true) { + return false; + } + + return this.configurationService.getValue(ChatConfiguration.SessionStateIndicatorEnabled) === true; + } + + /** Updates the whole-widget session state indicator. */ + private updateSessionStateIndicator(): void { + if (!this.container) { + return; + } + + const enabled = this.isSessionStateIndicatorEnabled(); + const modelNeedsInput = !!this.viewModel?.model.requestNeedsInput.get(); + const indicatorState = computeChatSessionStateIndicatorState({ + requestNeedsInput: modelNeedsInput, + requestInProgress: !!this.viewModel?.model.requestInProgress.get(), + containsFocus: dom.isAncestorOfActiveElement(this.container), + requestWasActive: this._requestActiveForStateIndicator, + hasUnvisitedCompletion: this._hasUnvisitedCompletion, + }); + this._requestActiveForStateIndicator = indicatorState.requestActive; + this._hasUnvisitedCompletion = indicatorState.hasUnvisitedCompletion; + + const needsInput = enabled && indicatorState.state === 'needsInput'; + const inProgress = enabled && indicatorState.state === 'inProgress'; + const idle = enabled && !needsInput && !inProgress; + const idleUnvisited = idle && this._hasUnvisitedCompletion; + + this.container.classList.toggle('chat-session-state-indicator', enabled); + this.container.classList.toggle('chat-state-needs-input', needsInput); + this.container.classList.toggle('chat-state-in-progress', inProgress); + this.container.classList.toggle('chat-state-idle', idle); + this.container.classList.toggle('chat-state-idle-unvisited', idleUnvisited); + } + get inputEditor(): ICodeEditor { return this.input.inputEditor; } @@ -1013,6 +1079,13 @@ export class ChatWidget extends Disposable implements IChatWidget { const renderInputToolbarBelowInput = this.viewOptions.renderInputToolbarBelowInput ?? false; this.container = dom.append(parent, $('.interactive-session')); + const focusTracker = this._register(dom.trackFocus(this.container)); + this._register(focusTracker.onDidFocus(() => { + if (this._hasUnvisitedCompletion) { + this.updateSessionStateIndicator(); + } + })); + this.updateSessionStateIndicator(); if (this.viewOptions.persistentContentHeight) { // The class floats the persistent content; the variable tells the // surfaces the list now extends behind how far to keep clear. @@ -2706,6 +2779,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.requestInProgress.set(this.viewModel.model.requestInProgress.get()); this.hasActiveRequest.set(this.viewModel.model.hasActiveRequest.get()); this.updateWorkingProgressBorder(); + this.updateSessionStateIndicator(); // Update the editor's placeholder text when it changes in the view model if (events?.some(e => e?.kind === 'changePlaceholder')) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index 5aa707b74f71c..c8c38509f0db6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -14,7 +14,6 @@ import { EditorOption } from '../../../../../../../editor/common/config/editorOp import { Position } from '../../../../../../../editor/common/core/position.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../../../editor/common/editorCommon.js'; -import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; import { ILabelService } from '../../../../../../../platform/label/common/label.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; import { getInputPlaceholderColor, getRangeForPlaceholder } from './chatInputPlaceholderDecoration.js'; @@ -25,7 +24,7 @@ import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynami import { agentReg, slashReg, variableReg } from '../../../../common/requestParser/chatRequestParser.js'; import { IChatWidget } from '../../../chat.js'; import { ChatWidget } from '../../chatWidget.js'; -import { dynamicVariableDecorationType } from '../../../attachments/chatDynamicVariables.js'; +import { registerChatInputReferenceDecorationType } from './chatInputReferenceDecorations.js'; import { NativeEditContextRegistry } from '../../../../../../../editor/browser/controller/editContext/native/nativeEditContextRegistry.js'; import { TextAreaEditContextRegistry } from '../../../../../../../editor/browser/controller/editContext/textArea/textAreaEditContextRegistry.js'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; @@ -185,12 +184,7 @@ class InputEditorDecorations extends Disposable { backgroundColor: themeColorFromId(chatSlashCommandBackground), borderRadius: '3px' })); - this._register(this.codeEditorService.registerDecorationType(decorationDescription, dynamicVariableDecorationType, { - color: themeColorFromId(chatSlashCommandForeground), - backgroundColor: themeColorFromId(chatSlashCommandBackground), - borderRadius: '3px', - rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges - })); + this._register(registerChatInputReferenceDecorationType(this.codeEditorService)); } private getPlaceholderColor(): string | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts new file mode 100644 index 0000000000000..0956f3fd9dc72 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../../../../base/common/themables.js'; +import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; +import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; +import { dynamicVariableDecorationType } from '../../../attachments/chatDynamicVariables.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; + +export function registerChatInputReferenceDecorationType(codeEditorService: ICodeEditorService, decorationType = dynamicVariableDecorationType): IDisposable { + return codeEditorService.registerDecorationType('chat', decorationType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }); +} 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 b7bcf2da95e27..1cd7457f96635 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -26,6 +26,78 @@ --vscode-chat-font-size-body-xxl: 1.538em; } +@keyframes chat-session-working-glow { + 0%, + 100% { + box-shadow: 0 0 var(--vscode-spacing-size20) color-mix(in srgb, var(--vscode-charts-green) 35%, transparent); + } + + 50% { + box-shadow: 0 0 var(--vscode-spacing-size60) color-mix(in srgb, var(--vscode-charts-green) 80%, transparent); + } +} + +.monaco-workbench .interactive-session.chat-session-state-indicator:is(.chat-state-idle-unvisited, .chat-state-in-progress, .chat-state-needs-input)::before { + content: ''; + position: absolute; + inset: 0; + /* Paint above the Prompt Timeline sticky header (9) and rail (15). */ + z-index: 16; + pointer-events: none; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-medium); + transition: border-color 350ms ease, box-shadow 350ms ease; +} + +.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-idle-unvisited::before { + border-color: hsl(from var(--vscode-foreground) h 0% l / 0.7); + border-style: dotted; +} + +.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + border-color: var(--vscode-charts-green); + border-style: solid; + animation: chat-session-working-glow 1.4s ease-in-out infinite; +} + +.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-needs-input::before { + border-color: var(--vscode-errorForeground); + border-style: dashed; + box-shadow: 0 0 var(--vscode-spacing-size80) color-mix(in srgb, var(--vscode-errorForeground) 60%, transparent); +} + +.monaco-workbench.hc-black .interactive-session.chat-session-state-indicator::before, +.monaco-workbench.hc-light .interactive-session.chat-session-state-indicator::before { + border-color: var(--vscode-contrastActiveBorder); + box-shadow: none; +} + +.monaco-workbench.hc-black .interactive-session.chat-session-state-indicator.chat-state-in-progress::before, +.monaco-workbench.hc-light .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + border: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + animation: none; +} + +.monaco-reduce-motion .interactive-session.chat-session-state-indicator::before, +.monaco-workbench.monaco-reduce-motion .interactive-session.chat-session-state-indicator::before { + transition: none; +} + +.monaco-reduce-motion .interactive-session.chat-session-state-indicator.chat-state-in-progress::before, +.monaco-workbench.monaco-reduce-motion .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + animation: none; +} + +@media (prefers-reduced-motion: reduce) { + .monaco-workbench .interactive-session.chat-session-state-indicator::before { + transition: none; + } + + .monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + animation: none; + } +} + .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row > .monaco-tl-row > .monaco-tl-twistie, .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container .monaco-tree-sticky-row > .monaco-tl-row > .monaco-tl-twistie { /* Hide twisties from chat tree rows, but not from nested trees within a chat response */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index a1ad9b50783a7..d83dfdee8b441 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -130,6 +130,7 @@ export class ChatEditor extends AbstractEditorWithViewState(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING); if (!fileLoggingEnabled) { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 55f4569cd5768..4c5cd079f85ae 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -87,6 +87,7 @@ export enum ChatConfiguration { ChatContextUsageEnabled = 'chat.contextUsage.enabled', Verbose = 'chat.verbose', ProgressBorder = 'chat.progressBorder.enabled', + SessionStateIndicatorEnabled = 'chat.experimental.sessionStateIndicator.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents', SubagentsUseRichRendering = 'chat.subagents.useRichRendering', diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index c94c7f7fc7fc1..528619edf8baf 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -41,11 +41,6 @@ export const SKILL_LANGUAGE_ID = 'skill'; */ export const ALL_PROMPTS_LANGUAGE_SELECTOR: LanguageSelector = [PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID, SKILL_LANGUAGE_ID]; -/** - * Configuration key for enabling the agent debug log feature. - */ -export const AGENT_DEBUG_LOG_ENABLED_SETTING = 'github.copilot.chat.agentDebugLog.enabled'; - /** * Configuration key for enabling file logging for the agent debug log. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 164c6b6bfd7a0..4463f828e74ea 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -120,16 +120,6 @@ export enum PromptsStorage { builtIn = 'builtin', } -/** - * Whether the AI Customizations UI offers Enable/Disable affordances for a - * customization with the given type and storage — and therefore whether a user - * who disables it can turn it back on again. - * - * Gate any behaviour that *hides* a customization because it is in - * {@link IPromptsService.getDisabledPromptFiles} on this predicate; that store - * is shared with surfaces that own a separate unhide affordance. See - * "Enabling and Disabling Built-in Skills" in `src/vs/sessions/AI_CUSTOMIZATIONS.md`. - */ export function isUserToggleableCustomization(type: PromptsType, storage: PromptsStorage): boolean { return type === PromptsType.skill && storage === PromptsStorage.builtIn; } diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index b24af21b51759..49e952a108d04 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -14,7 +14,7 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ChatResponseAccessibleView, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; -import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; +import { IChatExtensionsContent, IChatPullRequestContent, IChatSessionCreatedData, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; suite('ChatResponseAccessibleView', () => { @@ -228,6 +228,16 @@ suite('ChatResponseAccessibleView', () => { 'Edited an automation: Morning review', ]); }); + + test('describes a created session using its full title when available', () => { + const sessionData: IChatSessionCreatedData = { + kind: 'sessionCreated', + openLink: 'agent-host-session://local/session', + label: 'Implement issue', + fullTitle: 'Implement issue and validate the fix', + }; + assert.strictEqual(getToolSpecificDataDescription(sessionData), 'Implement issue and validate the fix'); + }); }); suite('getResultDetailsDescription', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts index fc64462616f04..260e3a992d4f4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts @@ -6,6 +6,8 @@ import assert from 'assert'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; @@ -20,8 +22,17 @@ import { AgentCustomizationItemProvider } from '../../../browser/agentSessions/a import { NullAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; +function makePromptsService(): IPromptsService { + return upcastPartial({ + onDidChangeSkills: Event.None, + getDisabledPromptFiles: () => new ResourceSet(), + listPromptFilesForStorage: async () => [], + }); +} + suite('AgentCustomizationItemProvider', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -60,6 +71,7 @@ suite('AgentCustomizationItemProvider', () => { fileService, new NullLogService(), new TestCustomizationService(), + makePromptsService(), )); provider.setDraftCustomizations(observableValue('draftCustomizations', [{ type: CustomizationType.Plugin, @@ -113,6 +125,7 @@ suite('AgentCustomizationItemProvider', () => { upcastPartial({}), new NullLogService(), new TestCustomizationService(), + makePromptsService(), )); const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); @@ -132,6 +145,58 @@ suite('AgentCustomizationItemProvider', () => { }]); }); + test('overrides a stale enabled provider row when its built-in skill is user-disabled', async () => { + const bundleUri = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/bundle' }); + const bundledSkillUri = URI.joinPath(bundleUri, 'skills', 'create-pr', 'SKILL.md'); + const builtinSkillUri = URI.file('/builtin/create-pr/SKILL.md'); + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(SYNCED_CUSTOMIZATION_SCHEME, disposables.add(new InMemoryFileSystemProvider()))); + await fileService.writeFile(bundledSkillUri, VSBuffer.fromString('---\nname: create-pr\ndescription: Create a pull request.\n---\nCreate it.')); + const promptsService = upcastPartial({ + onDidChangeSkills: Event.None, + getDisabledPromptFiles: () => new ResourceSet([builtinSkillUri]), + listPromptFilesForStorage: async () => [{ + uri: builtinSkillUri, + type: PromptsType.skill, + storage: PromptsStorage.builtIn, + name: 'create-pr', + description: 'Create a pull request.', + } as IPromptPath], + }); + const provider = disposables.add(new AgentCustomizationItemProvider( + 'local', + undefined, + syncedUri => syncedUri.toString() === bundledSkillUri.toString() + ? { uri: builtinSkillUri, source: AICustomizationSources.builtin } + : undefined, + fileService, + new NullLogService(), + new NullAgentHostCustomizationService(), + promptsService, + )); + provider.setDraftCustomizations(observableValue('draftCustomizations', [{ + type: CustomizationType.Plugin, + id: bundleUri.toString(), + uri: bundleUri.toString(), + name: 'VS Code Synced Data', + nonce: '1', + }])); + + const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///draft'), CancellationToken.None); + + assert.deepStrictEqual(items.map(item => ({ + uri: item.uri.toString(), + type: item.type, + source: item.source, + enabled: item.enabled, + })), [{ + uri: builtinSkillUri.toString(), + type: PromptsType.skill, + source: AICustomizationSources.builtin, + enabled: false, + }]); + }); + test('surfaces only the host-published winning disabled reason', async () => { const customizations: PluginCustomization[] = [ { @@ -164,6 +229,7 @@ suite('AgentCustomizationItemProvider', () => { upcastPartial({}), new NullLogService(), new TestCustomizationService(), + makePromptsService(), )); const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); @@ -184,4 +250,55 @@ suite('AgentCustomizationItemProvider', () => { }, ]); }); + + test('supplements provider output with user-disabled built-in skills', async () => { + const disabledSkill = URI.file('/builtin/create-pr/SKILL.md'); + let disabledPromptFiles = new ResourceSet([disabledSkill]); + const onDidChangeSkills = disposables.add(new Emitter()); + const promptsService = upcastPartial({ + onDidChangeSkills: onDidChangeSkills.event, + getDisabledPromptFiles: () => disabledPromptFiles, + listPromptFilesForStorage: async (type: PromptsType, storage: PromptsStorage) => type === PromptsType.skill && storage === PromptsStorage.builtIn + ? [{ uri: disabledSkill, type, storage, name: 'create-pr', description: 'Create a pull request.' } satisfies IPromptPath] + : [], + }); + const provider = disposables.add(new AgentCustomizationItemProvider( + 'local', + undefined, + undefined, + upcastPartial({}), + new NullLogService(), + new NullAgentHostCustomizationService(), + promptsService, + )); + let changeCount = 0; + disposables.add(provider.onDidChange(() => changeCount++)); + + const disabledItems = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); + disabledPromptFiles = new ResourceSet(); + onDidChangeSkills.fire(); + const enabledItems = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); + + assert.deepStrictEqual({ + disabledItems: disabledItems.map(item => ({ + uri: item.uri.toString(), + type: item.type, + name: item.name, + source: item.source, + enabled: item.enabled, + })), + changeCount, + enabledItems, + }, { + disabledItems: [{ + uri: disabledSkill.toString(), + type: PromptsType.skill, + name: 'create-pr', + source: AICustomizationSources.builtin, + enabled: false, + }], + changeCount: 1, + enabledItems: [], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index 9cc0af6745f49..f645be3abd683 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -11,7 +11,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ClientAnnotationsAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ConfigPropertySchema, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -77,6 +77,7 @@ const fullSchema: Record = { [CopilotCliConfigKey.ToolSearchDeferThreshold]: { type: 'number', title: 'Tool Search Defer Threshold' }, [CopilotCliConfigKey.ReasoningSummary]: { type: 'boolean', title: 'Reasoning Summary' }, [CopilotCliConfigKey.MultiTurnContextRouting]: { type: 'boolean', title: 'Auto Multi-Turn Context Routing' }, + [CopilotCliConfigKey.AutoModeTiers]: { type: 'boolean', title: 'Auto Routing Profiles' }, [CopilotCliConfigKey.SubagentModelGuidance]: { type: 'boolean', title: 'Subagent Model Guidance' }, [CopilotCliConfigKey.ModelCapabilityOverrides]: { type: 'object', title: 'Model Capability Overrides' }, }; @@ -122,6 +123,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [AgentHostCopilotModelCapabilityOverridesSettingId]: capabilityOverrides, [AgentHostReasoningSummaryEnabledSettingId]: true, [AgentHostMultiTurnContextRoutingEnabledSettingId]: true, + [AgentHostAutoModeTiersEnabledSettingId]: true, [CopilotSubagentModelGuidanceEnabledSettingId]: true, }); agentHostService.setRootState(makeRootStateWithSchema(fullSchema)); @@ -129,7 +131,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { // The shared forwarder dispatches one RootConfigChanged per key; merge them // and assert the full forwarded set (order-independent). - assert.strictEqual(agentHostService.dispatchedActions.length, 8); + assert.strictEqual(agentHostService.dispatchedActions.length, 9); const merged = Object.assign({}, ...agentHostService.dispatchedActions.map(a => (a.action as IRootConfigChangedAction).config)); assert.deepStrictEqual(merged, { [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace', @@ -138,6 +140,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.ToolSearchDeferThreshold]: 5, [CopilotCliConfigKey.ReasoningSummary]: true, [CopilotCliConfigKey.MultiTurnContextRouting]: true, + [CopilotCliConfigKey.AutoModeTiers]: true, [CopilotCliConfigKey.SubagentModelGuidance]: true, [CopilotCliConfigKey.ModelCapabilityOverrides]: capabilityOverrides, }); @@ -194,6 +197,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.ToolSearchDeferThreshold]: 1, [CopilotCliConfigKey.ReasoningSummary]: false, [CopilotCliConfigKey.MultiTurnContextRouting]: false, + [CopilotCliConfigKey.AutoModeTiers]: false, [CopilotCliConfigKey.SubagentModelGuidance]: false, [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, })); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index eb86eab00d024..4325473e400aa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; @@ -136,6 +137,28 @@ suite('AgentHostLanguageModelProvider', () => { ]); }); + test('carries model notices and flags row warnings', async () => { + const provider = createProvider(); + provider.updateModels([makeModel('gpt-5', { + warningText: { model_degraded: 'GPT-5 is currently degraded.' }, + infoText: { model_relocated: 'GPT-5 now serves from a new region.' }, + rowWarning: 'GPT-5 is currently degraded.', + })]); + + const metadata = (await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None))[0].metadata; + assert.deepStrictEqual({ + tooltip: metadata.tooltip, + statusIcon: metadata.statusIcon?.id, + warningText: metadata.warningText, + infoText: metadata.infoText, + }, { + tooltip: 'GPT-5 is currently degraded.', + statusIcon: Codicon.warning.id, + warningText: { model_degraded: 'GPT-5 is currently degraded.' }, + infoText: { model_relocated: 'GPT-5 now serves from a new region.' }, + }); + }); + test('derives the picker group from the model-id prefix, not the harness provider', async () => { const provider = createProvider(); // The agent host reports every model under the harness provider (`copilotcli`); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index d9751e4d89716..59ff699fd4eb9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2621,7 +2621,7 @@ suite('AgentSessions', () => { test('should return correct icon for AgentHostCopilot provider', () => { const icon = getAgentSessionProviderIcon(AgentSessionProviders.AgentHostCopilot); - assert.strictEqual(icon.id, Codicon.vm.id); + assert.strictEqual(icon.id, Codicon.copilot.id); }); test('should return simplified AgentHostCopilot name', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts index b1973ba3ebf5d..c9527532bc89e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts @@ -95,6 +95,28 @@ suite('enumerateLocalCustomizationsForHarness', () => { assert.strictEqual(result[0].disabled, true); }); + test('honors user enablement only for built-in skills', async () => { + const builtinSkill = URI.file('/builtin/create-pr/SKILL.md'); + const extensionAgent = URI.file('/extension/agents/reviewer.agent.md'); + const promptsService = makePromptsService( + new Map([ + [`${PromptsType.skill}/${BUILTIN_STORAGE}`, [makePromptPath(builtinSkill, PromptsType.skill, PromptsStorage.builtIn)]], + [`${PromptsType.agent}/${PromptsStorage.extension}`, [makePromptPath(extensionAgent, PromptsType.agent, PromptsStorage.extension)]], + ]), + new Map([ + [PromptsType.skill, new ResourceSet([builtinSkill])], + [PromptsType.agent, new ResourceSet([extensionAgent])], + ]), + ); + + const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None, undefined); + + assert.deepStrictEqual(result.map(item => ({ uri: item.uri.toString(), disabled: item.disabled })), [ + { uri: extensionAgent.toString(), disabled: false }, + { uri: builtinSkill.toString(), disabled: true }, + ]); + }); + test('includes all user prompt types only when user storage is enabled', async () => { const userAgent = URI.file('/home/user/.copilot/agents/user.agent.md'); const userSkill = URI.file('/home/user/.claude/skills/user-skill/SKILL.md'); @@ -148,54 +170,6 @@ suite('enumerateLocalCustomizationsForHarness', () => { ]); }); - test('marks built-in skills disabled when the user disabled them in the Customizations UI', async () => { - // The Enable/Disable actions write to `IPromptsService`, not to the - // per-harness sync provider. Both stores must be honored, otherwise a - // disabled built-in skill would still be synced to the agent host. - const disabledSkill = URI.file('/builtin/create-pr/SKILL.md'); - const enabledSkill = URI.file('/builtin/merge/SKILL.md'); - const promptsService = makePromptsService( - new Map([ - [`${PromptsType.skill}/${BUILTIN_STORAGE}`, [ - makePromptPath(disabledSkill, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), - makePromptPath(enabledSkill, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), - ]], - ]), - new Map([[PromptsType.skill, new ResourceSet([disabledSkill])]]), - ); - - const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None, undefined); - - assert.deepStrictEqual(result.map(item => ({ uri: item.uri.toString(), disabled: item.disabled })), [ - { uri: disabledSkill.toString(), disabled: true }, - { uri: enabledSkill.toString(), disabled: false }, - ]); - }); - - test('does not honor the user-disabled store for prompt types the Customizations UI cannot re-enable', async () => { - // `getDisabledPromptFiles(agent)` is also written by the chat view agent - // picker ("hidden from agent picker"). The Customizations UI registers - // Enable/Disable only for built-in skills, so it has no way to bring a - // hidden agent back. Dropping it from the bundle would remove it from the - // Agents-window list too, stranding it permanently — so the wire must - // ignore that store here and leave the agent enabled. - const hiddenAgent = URI.file('/extension/agents/reviewer.agent.md'); - const promptsService = makePromptsService( - new Map([ - [`${PromptsType.agent}/${PromptsStorage.extension}`, [ - makePromptPath(hiddenAgent, PromptsType.agent, PromptsStorage.extension), - ]], - ]), - new Map([[PromptsType.agent, new ResourceSet([hiddenAgent])]]), - ); - - const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None, undefined); - - assert.deepStrictEqual(result.map(item => ({ uri: item.uri.toString(), disabled: item.disabled })), [ - { uri: hiddenAgent.toString(), disabled: false }, - ]); - }); - test('returns empty when the prompts service exposes no built-in skills (regular workbench)', async () => { // The regular workbench's PromptsServiceImpl treats `builtin` as a // first-class storage that simply yields no files (rather than diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index dbd5ae2d5952d..f87a74a02303f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -7,8 +7,8 @@ import assert from 'assert'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PluginFormat } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -258,17 +258,14 @@ suite('resolveCustomizationRefs - built-in skills', () => { assert.deepStrictEqual(bundler.received[0].map(f => f.uri.toString()), [enabled.toString()]); }); - test('omits built-in skills the user disabled in the Customizations UI from the bundle', async () => { - // Regression: the Enable/Disable actions write to `IPromptsService`, - // not to the per-harness sync provider, so a skill disabled from the UI - // must still be dropped from the bundle sent to the agent host. + test('omits user-disabled built-in skills from the bundle', async () => { const enabled = URI.file('/builtin/create-pr/SKILL.md'); const disabled = URI.file('/builtin/merge/SKILL.md'); const promptsService = makePromptsService( new Map([ [`${PromptsType.skill}/${BUILTIN_STORAGE}`, [ - makePromptPath(enabled, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), - makePromptPath(disabled, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), + makePromptPath(enabled, PromptsType.skill, PromptsStorage.builtIn), + makePromptPath(disabled, PromptsType.skill, PromptsStorage.builtIn), ]], ]), new Map([[PromptsType.skill, new ResourceSet([disabled])]]), @@ -287,7 +284,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { undefined, ); - assert.deepStrictEqual(bundler.received[0].map(f => f.uri.toString()), [enabled.toString()]); + assert.deepStrictEqual(bundler.received[0].map(file => file.uri.toString()), [enabled.toString()]); }); test('combines built-in skills with user files in a single bundle', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 9db7ad037680d..2ad3ed2fb964c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -806,16 +806,10 @@ suite('AICustomizationItemsModel', () => { let disposables: DisposableStore; let instaService: TestInstantiationService; let providerItems: ICustomizationItem[]; - let builtinSkills: IPromptPath[]; - let disabledPromptFiles: ResourceSet; - let onDidChangeSkills: Emitter; setup(() => { disposables = new DisposableStore(); providerItems = []; - builtinSkills = []; - disabledPromptFiles = new ResourceSet(); - onDidChangeSkills = disposables.add(new Emitter()); const sessionType = 'agent-host-test'; const provider: ICustomizationItemProvider = { @@ -835,19 +829,17 @@ suite('AICustomizationItemsModel', () => { instaService.stub(IPromptsService, { onDidChangeCustomAgents: Event.None, onDidChangeSlashCommands: Event.None, - onDidChangeSkills: onDidChangeSkills.event, + onDidChangeSkills: Event.None, onDidChangeHooks: Event.None, onDidChangeInstructions: Event.None, onDidChangeAgentInstructions: Event.None, listPromptFiles: async () => [], - listPromptFilesForStorage: async (type: PromptsType, storage: PromptsStorage) => ( - type === PromptsType.skill && storage === PromptsStorage.builtIn ? builtinSkills.slice() : [] - ), + listPromptFilesForStorage: async () => [], getCustomAgents: async () => [], findAgentSkills: async () => [], getHooks: async () => undefined, getInstructionFiles: async () => [], - getDisabledPromptFiles: () => disabledPromptFiles, + getDisabledPromptFiles: () => new ResourceSet(), }); instaService.stub(IAICustomizationWorkspaceService, { activeProjectRoot: observableValue('test', undefined), @@ -915,73 +907,5 @@ suite('AICustomizationItemsModel', () => { }, ); }); - - // Regression: on agent-host harnesses the Skills list used to render - // straight from the provider, which reports the synced *bundle*. A - // built-in skill disabled from the Customizations UI is dropped from - // that bundle, so the skill vanished from the list instead of showing - // as disabled — leaving no way to re-enable it and making the Disable - // button look like a no-op. Built-ins are now merged in from the - // prompts service, which owns the enable/disable state. - test('lists disabled built-in skills as disabled instead of dropping them', async () => { - const disabledSkill = URI.file('/builtin/create-pr/SKILL.md'); - const enabledSkill = URI.file('/builtin/merge/SKILL.md'); - builtinSkills = [ - { uri: disabledSkill, type: PromptsType.skill, storage: PromptsStorage.builtIn, name: 'create-pr' } as IPromptPath, - { uri: enabledSkill, type: PromptsType.skill, storage: PromptsStorage.builtIn, name: 'merge' } as IPromptPath, - ]; - disabledPromptFiles = new ResourceSet([disabledSkill]); - // The provider only reports the still-bundled skill; the disabled - // one is absent because it was excluded from the synced bundle. - providerItems = [ - { uri: enabledSkill, type: PromptsType.skill, name: 'merge', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, extensionId: undefined, pluginUri: undefined, userInvocable: true }, - ]; - - const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); - const skillItems = model.getItems(AICustomizationManagementSection.Skills); - await model.whenSectionLoaded(AICustomizationManagementSection.Skills); - - assert.deepStrictEqual( - skillItems.get().map(i => ({ name: i.name, source: i.source, groupKey: i.groupKey, disabled: i.disabled })).sort((a, b) => a.name.localeCompare(b.name)), - [ - { name: 'create-pr', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, disabled: true }, - { name: 'merge', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, disabled: false }, - ], - ); - }); - test('refreshes built-in skill disabled state when onDidChangeSkills fires', async () => { - // The Disable action writes to IPromptsService and fires - // onDidChangeSkills; the provider is unchanged (its bundle refresh is - // asynchronous and may lag). PureItemProviderItemSource must still - // re-derive `disabled` from the prompts service, otherwise the row - // would stay stale until some unrelated provider change happened. - const skill = URI.file('/builtin/create-pr/SKILL.md'); - builtinSkills = [ - { uri: skill, type: PromptsType.skill, storage: PromptsStorage.builtIn, name: 'create-pr' } as IPromptPath, - ]; - providerItems = [ - { uri: skill, type: PromptsType.skill, name: 'create-pr', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, extensionId: undefined, pluginUri: undefined, userInvocable: true }, - ]; - - const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); - const skillItems = model.getItems(AICustomizationManagementSection.Skills); - await model.whenSectionLoaded(AICustomizationManagementSection.Skills); - // Let any refetch scheduled during construction settle, so the - // assertion below can only be satisfied by a refetch that the - // onDidChangeSkills subscription itself triggered. - await timeout(0); - assert.deepStrictEqual(skillItems.get().map(i => ({ name: i.name, disabled: i.disabled })), [ - { name: 'create-pr', disabled: false }, - ]); - - disabledPromptFiles = new ResourceSet([skill]); - onDidChangeSkills.fire(); - await timeout(0); - await model.whenSectionLoaded(AICustomizationManagementSection.Skills); - - assert.deepStrictEqual(skillItems.get().map(i => ({ name: i.name, disabled: i.disabled })), [ - { name: 'create-pr', disabled: true }, - ]); - }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index fdd88deaf3647..5a45016767a4f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -5,12 +5,16 @@ import assert from 'assert'; import sinon from 'sinon'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ChatSpeechToTextService, createDictationCleanupSystemPrompt, isDictationEntitled, selectFinalDictationTranscript, stripDictationFillers } from '../../browser/speechToText/chatSpeechToTextService.js'; import { resolveDictationLanguage } from '../../browser/speechToText/dictationLanguage.js'; import { ChatEntitlement } from '../../../../services/chat/common/chatEntitlementService.js'; import { ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; +import { IVoiceClientService, IVoiceFatalDisconnect } from '../../common/voiceClient/voiceClientService.js'; type CleanupTestService = { _configurationService: { @@ -29,6 +33,37 @@ type CleanupTestService = { _cleanupWithLanguageModel: (text: string, token: CancellationToken) => Promise; }; +type ConfiguredTestService = { + _configurationService: { getValue: () => boolean }; + _getBackend: () => 'mai'; + _isEntitledForBackend: () => boolean; + _voiceWsUrl: () => string; + _hasGitHubSession: boolean; + _localTranscription: { isSupported: boolean }; + readonly isConfigured: boolean; +}; + +type ConnectionTestService = { + _voiceClientService: Pick; + _maiSessionDisposables: DisposableStore; + _sessionErrorCode: string; + _sessionCloseCode: number; + _awaitVoiceConnected: () => Promise; +}; + +type FinalizationTestService = { + _activeBackend: 'nemo'; + _localTranscription: { + stop: () => Promise; + cancel: () => Promise; + }; + _finalizedText: string; + _deltaText: string; + _logService: Pick; + _pendingLocalTeardown: Promise | undefined; + _finishBackend: () => Promise; +}; + suite('ChatSpeechToTextService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -61,6 +96,88 @@ suite('ChatSpeechToTextService', () => { }); }); + test('requires a GitHub session before cloud dictation is configured', () => { + const service = Object.create(ChatSpeechToTextService.prototype) as ConfiguredTestService; + service._configurationService = { getValue: () => true }; + service._getBackend = () => 'mai'; + service._isEntitledForBackend = () => true; + service._voiceWsUrl = () => 'wss://voice.example.com'; + service._localTranscription = { isSupported: true }; + + service._hasGitHubSession = false; + const signedOut = service.isConfigured; + service._hasGitHubSession = true; + const signedIn = service.isConfigured; + + assert.deepStrictEqual({ signedOut, signedIn }, { signedOut: false, signedIn: true }); + }); + + test('rejects cloud connection immediately on a fatal disconnect', async () => { + const fatalDisconnect = new Emitter(); + const sessionDisposables = new DisposableStore(); + const service = Object.create(ChatSpeechToTextService.prototype) as ConnectionTestService; + service._voiceClientService = { + isConnected: false, + onDidChangeConnectionState: Event.None, + onFatalDisconnect: fatalDisconnect.event, + }; + service._maiSessionDisposables = sessionDisposables; + service._sessionErrorCode = ''; + service._sessionCloseCode = 0; + + const connected = service._awaitVoiceConnected(); + fatalDisconnect.fire({ code: 4008, reason: 'rejected' }); + + await assert.rejects(connected, /code 4008/); + assert.deepStrictEqual({ + errorCode: service._sessionErrorCode, + closeCode: service._sessionCloseCode, + }, { + errorCode: 'connect.rejected.4008', + closeCode: 4008, + }); + + fatalDisconnect.dispose(); + sessionDisposables.dispose(); + }); + + test('returns the streamed transcript when on-device finalization times out', async () => { + const clock = sinon.useFakeTimers(); + const warnings: string[] = []; + const stop = new DeferredPromise(); + let cancellations = 0; + const service = Object.create(ChatSpeechToTextService.prototype) as FinalizationTestService; + service._activeBackend = 'nemo'; + service._finalizedText = 'streamed transcript'; + service._deltaText = ''; + service._logService = { warn: message => warnings.push(message) }; + service._localTranscription = { + stop: () => stop.p, + cancel: async () => { cancellations++; }, + }; + + try { + const resultPromise = service._finishBackend(); + await clock.tickAsync(8000); + + assert.deepStrictEqual({ + result: await resultPromise, + cancellations, + teardownPending: service._pendingLocalTeardown !== undefined, + warnings, + }, { + result: 'streamed transcript', + cancellations: 1, + teardownPending: true, + warnings: ['[chat-stt] on-device final transcription timed out after 8000ms; using streamed transcript'], + }); + stop.complete(''); + await service._pendingLocalTeardown; + } finally { + clock.restore(); + } + }); + test('resolves the dictation language from Voice Mode configuration, display language, and browser locale', () => { assert.deepStrictEqual({ explicit: resolveDictationLanguage('fr-FR', 'de-DE'), diff --git a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts index 02e14783e8d52..6d58c91fa7db6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { mainWindow } from '../../../../../base/browser/window.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -120,6 +121,29 @@ suite('DictationSession', () => { assert.deepStrictEqual([interimValue, editor.getValue()], ['', transcript]); }); + test('stops and inserts the final transcript after 20 minutes', async () => { + const transcript = 'hello world'; + const { service } = createService(transcript, false); + const model = store.add(createTextModel('')); + const editor = store.add(createTestCodeEditor(model)); + const clock = sinon.useFakeTimers(); + + try { + await startDictation(service, editor, mainWindow, new NullLogService()); + await clock.tickAsync(20 * 60 * 1000); + + assert.deepStrictEqual({ + isDictating: isDictating(), + value: editor.getValue(), + }, { + isDictating: false, + value: transcript, + }); + } finally { + clock.restore(); + } + }); + test('stops only when the submitted editor owns dictation', async () => { const { service } = createService('hello world', true); const dictationEditor = store.add(createTestCodeEditor(store.add(createTextModel('')))); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index f8523eb4bdeb1..db1664ba3e2db 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -17,8 +17,8 @@ import { IAccessibilityService } from '../../../../../../platform/accessibility/ import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService as BaseTestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotification, INotificationHandle, INotificationService, IPromptChoice, NoOpNotification, Severity } from '../../../../../../platform/notification/common/notification.js'; import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; @@ -47,6 +47,12 @@ import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; import { AskQuestionsToolId } from '../../../common/tools/builtinTools/askQuestionsTool.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; +class TestConfigurationService extends BaseTestConfigurationService { + constructor(configuration: Record = {}) { + super({ 'agents.voice.enabled': true, ...configuration }); + } +} + class TestVoiceClientService extends mock() { private narrationCounter = 0; readonly requests: { sessionId: string; kind: VoiceNarrationKind; text: string; narrationId: string; pendingId?: string; checkpoint?: IVoiceCheckpointNarrationMetadata; confirmationType?: VoiceConfirmationType }[] = []; @@ -827,32 +833,104 @@ suite('VoiceSessionController', () => { assert.deepStrictEqual(notificationService.notifications.map(notification => notification.message), ['Voice Mode requires a paid GitHub Copilot plan.']); }); - test('disconnects when the paid Copilot entitlement is lost', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatEntitlementService = new MutableTestChatEntitlementService(); - chatEntitlementService.entitlement = ChatEntitlement.Pro; + test('does not connect when Voice Mode is disabled', async () => { + const notificationService = new VoiceTestNotificationService(); const controller = createController( - voiceClientService, - undefined, - undefined, + new TestVoiceClientService(), undefined, undefined, undefined, undefined, + new TestConfigurationService({ 'agents.voice.enabled': false }), undefined, undefined, undefined, - chatEntitlementService, + notificationService, ); - controller['_isConnected'].set(true, undefined); - chatEntitlementService.setEntitlement(ChatEntitlement.Free); - await new Promise(resolve => queueMicrotask(resolve)); + await controller.connect(mainWindow); - assert.strictEqual(controller.isConnected.get(), false); + assert.deepStrictEqual({ + connecting: controller.isConnecting.get(), + connected: controller.isConnected.get(), + notifications: notificationService.notifications.map(notification => notification.message), + }, { + connecting: false, + connected: false, + notifications: ['Voice Mode is disabled.'], + }); + }); + + test('disconnects active and in-flight connections when Voice Mode becomes disabled', async () => { + const results = []; + for (const state of ['connecting', 'connected'] as const) { + const configurationService = new TestConfigurationService({ 'agents.voice.enabled': true }); + const controller = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + configurationService, + ); + if (state === 'connecting') { + controller['_isConnecting'].set(true, undefined); + } else { + controller['_isConnected'].set(true, undefined); + } + + await configurationService.setUserConfiguration('agents.voice.enabled', false); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === 'agents.voice.enabled'; + } + }); + results.push({ + state, + connecting: controller.isConnecting.get(), + connected: controller.isConnected.get(), + }); + } + + assert.deepStrictEqual(results, [ + { state: 'connecting', connecting: false, connected: false }, + { state: 'connected', connecting: false, connected: false }, + ]); + }); + + test('disconnects when the Copilot entitlement becomes ineligible', async () => { + const results = []; + for (const entitlement of [ChatEntitlement.Free, ChatEntitlement.Business, ChatEntitlement.Enterprise]) { + const chatEntitlementService = new MutableTestChatEntitlementService(); + chatEntitlementService.entitlement = ChatEntitlement.Pro; + const controller = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + chatEntitlementService, + ); + controller['_isConnected'].set(true, undefined); + + chatEntitlementService.setEntitlement(entitlement); + await new Promise(resolve => queueMicrotask(resolve)); + results.push({ entitlement, connected: controller.isConnected.get() }); + } + + assert.deepStrictEqual(results, [ + { entitlement: ChatEntitlement.Free, connected: false }, + { entitlement: ChatEntitlement.Business, connected: false }, + { entitlement: ChatEntitlement.Enterprise, connected: false }, + ]); }); - test('stays connected across a paid-to-paid entitlement transition', async () => { + test('stays connected across an eligible paid-to-paid entitlement transition', async () => { const chatEntitlementService = new MutableTestChatEntitlementService(); chatEntitlementService.entitlement = ChatEntitlement.Pro; const controller = createController( @@ -870,29 +948,38 @@ suite('VoiceSessionController', () => { ); controller['_isConnected'].set(true, undefined); - chatEntitlementService.transitionEntitlement(ChatEntitlement.Unresolved, ChatEntitlement.Business); + chatEntitlementService.transitionEntitlement(ChatEntitlement.Unresolved, ChatEntitlement.ProPlus); await new Promise(resolve => queueMicrotask(resolve)); assert.strictEqual(controller.isConnected.get(), true); }); - test('restricts Voice Mode for external Enterprise users but allows internal staff', async () => { - const externalNotifications = new VoiceTestNotificationService(); - const externalEntitlement = new MutableTestChatEntitlementService(); - externalEntitlement.entitlement = ChatEntitlement.Enterprise; - const externalController = createController( - new TestVoiceClientService(), - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - externalNotifications, - externalEntitlement, - ); + test('restricts Voice Mode for external Business and Enterprise users but allows internal staff', async () => { + const externalResults = []; + for (const entitlement of [ChatEntitlement.Business, ChatEntitlement.Enterprise]) { + const notifications = new VoiceTestNotificationService(); + const entitlementService = new MutableTestChatEntitlementService(); + entitlementService.entitlement = entitlement; + const controller = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + notifications, + entitlementService, + ); + await controller.connect(mainWindow); + externalResults.push({ + entitlement, + connecting: controller.isConnecting.get(), + notifications: notifications.notifications.map(notification => notification.message), + }); + } const internalNotifications = new VoiceTestNotificationService(); const internalEntitlement = new InternalTestChatEntitlementService(); @@ -911,17 +998,25 @@ suite('VoiceSessionController', () => { internalEntitlement, ); - await externalController.connect(mainWindow); await internalController.connect(mainWindow); assert.deepStrictEqual({ - externalConnecting: externalController.isConnecting.get(), - externalNotifications: externalNotifications.notifications.map(notification => notification.message), + externalResults, internalConnecting: internalController.isConnecting.get(), internalNotifications: internalNotifications.notifications.map(notification => notification.message), }, { - externalConnecting: false, - externalNotifications: ['Voice Mode is not available for GitHub Copilot Enterprise accounts.'], + externalResults: [ + { + entitlement: ChatEntitlement.Business, + connecting: false, + notifications: ['Voice Mode is not available for GitHub Copilot Business or Enterprise accounts.'], + }, + { + entitlement: ChatEntitlement.Enterprise, + connecting: false, + notifications: ['Voice Mode is not available for GitHub Copilot Business or Enterprise accounts.'], + }, + ], internalConnecting: true, internalNotifications: [], }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts index 49ea2693f20f0..14c5d178f1b32 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts @@ -327,7 +327,7 @@ suite('ChatToolProgressSubPart', () => { assert.strictEqual(createInstanceStub.firstCall.args[0], ChatAutomationConfiguredResultSubPart); }); - test('renders a created session as a plain title link', () => { + test('renders a created session as a rich session link', () => { const updateHover = sinon.spy(); const setupManagedHoverStub = sinon.stub(mockHoverService, 'setupManagedHover').returns({ dispose() { }, @@ -357,17 +357,17 @@ suite('ChatToolProgressSubPart', () => { const link = part.domNode.querySelector('a.monaco-link'); assert.deepStrictEqual({ - text: link?.textContent, + title: link?.querySelector('.chat-rich-link-title')?.textContent, href: link?.getAttribute('href'), hoverTitle: updateHover.lastCall.args[0], - role: link?.getAttribute('role'), - hasButton: !!part.domNode.querySelector('.monaco-button'), + kind: link?.dataset.chatRichLinkKind, + hasRichLink: link?.classList.contains('chat-rich-link'), }, { - text: `${runningTitle.slice(0, 57)}…`, + title: runningTitle, href: 'agent-host-session://copilot/task-a', hoverTitle: runningTitle, - role: null, - hasButton: false, + kind: 'session', + hasRichLink: true, }); sessionLinkPresentation.set({ @@ -376,10 +376,10 @@ suite('ChatToolProgressSubPart', () => { status: { kind: 'success', label: 'Completed' }, }, undefined); assert.deepStrictEqual({ - text: link?.textContent, + title: link?.querySelector('.chat-rich-link-title')?.textContent, hoverTitle: updateHover.lastCall.args[0], }, { - text: 'Finished weather session', + title: 'Finished weather session', hoverTitle: 'Finished weather session', }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 9801d54025729..88976f16e8f93 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -210,6 +210,32 @@ suite('ChatListRenderer', () => { }); }); + test('deduplicates a created-session link echoed in the final response', () => { + const tool: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'create-session', + toolId: 'create_session', + invocationMessage: 'Creating session...', + originMessage: undefined, + pastTenseMessage: 'Created session', + isComplete: true, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + presentation: undefined, + source: ToolDataSource.Internal, + toolSpecificData: { + kind: 'sessionCreated', + openLink: 'agent-host-session://local/session', + label: 'Implement issue', + }, + }; + const finalResponse = { + kind: 'markdownContent', + content: new MarkdownString('Done: [Implement issue](agent-host-session://local/session)'), + } as const; + + assert.deepStrictEqual(moveResponseOutcomeToolsAfterFinalResponse([tool, finalResponse]), [finalResponse]); + }); + test('leaves created-session tools in place when there is no final response', () => { const tool: IChatToolInvocationSerialized = { kind: 'toolInvocationSerialized', 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 166f46374b43e..3a58e4c1e6fdb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -24,7 +24,7 @@ import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAcce import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; -import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_OVERLAY_CLASS, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_WINDOW_OWNERSHIP_CHANNEL, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, IChatPetWidgetHost, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnchoredHorizontalPosition, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalAnchor, getChatPetHorizontalPosition, getChatPetPillPlatformTop, getChatPetPlatformTop, getChatPetStackPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldClaimChatPetWindowOnConstruction, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; +import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_OVERLAY_CLASS, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, IChatPetWidgetHost, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnchoredHorizontalPosition, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalAnchor, getChatPetHorizontalPosition, getChatPetPillPlatformTop, getChatPetPlatformTop, getChatPetStackPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldClaimChatPetWindowOnConstruction, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; suite('ChatPetWidget', () => { @@ -576,69 +576,6 @@ suite('ChatPetWidget', () => { }); }); - test('keeps the pet on external-app blur but transfers it to another VS Code window', async () => { - const parent = mainWindow.document.createElement('div'); - const dragBounds = mainWindow.document.createElement('div'); - const movementBounds = mainWindow.document.createElement('div'); - mainWindow.document.body.append(parent, dragBounds, movementBounds); - disposables.add(toDisposable(() => { - parent.remove(); - dragBounds.remove(); - movementBounds.remove(); - })); - const hostService = new class extends mock() { - override readonly hasFocus = true; - override readonly onDidChangeFocus = Event.None; - override readonly onDidChangeActiveWindow = Event.None; - }(); - const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); - disposables.add(new ChatPetWidget( - createPetHost(parent, dragBounds, movementBounds), - undefined, - service, - new TestAccessibilityService(), - new class extends mock() { }(), - new class extends mock() { }(), - new NullLogService(), - hostService, - )); - const button = parent.getElementsByClassName('chat-pet-button')[0]; - service.toggle(); - const initiallyHidden = button.classList.contains('hidden'); - const ownershipChannel = new BroadcastChannel(CHAT_PET_WINDOW_OWNERSHIP_CHANNEL); - disposables.add(toDisposable(() => ownershipChannel.close())); - - mainWindow.dispatchEvent(new FocusEvent('blur')); - const hiddenAfterExternalBlur = button.classList.contains('hidden'); - const windowTransferred = new Promise(resolve => { - const observer = new mainWindow.MutationObserver(() => { - if (button.classList.contains('hidden')) { - observer.disconnect(); - resolve(); - } - }); - disposables.add(toDisposable(() => observer.disconnect())); - observer.observe(button, { attributes: true, attributeFilter: ['class'] }); - }); - ownershipChannel.postMessage({ windowId: mainWindow.vscodeWindowId + 1 }); - await windowTransferred; - const hiddenAfterWindowTransfer = button.classList.contains('hidden'); - mainWindow.dispatchEvent(new FocusEvent('focus')); - const hiddenAfterReturn = button.classList.contains('hidden'); - - assert.deepStrictEqual({ - initiallyHidden, - hiddenAfterExternalBlur, - hiddenAfterWindowTransfer, - hiddenAfterReturn, - }, { - initiallyHidden: false, - hiddenAfterExternalBlur: false, - hiddenAfterWindowTransfer: true, - hiddenAfterReturn: false, - }); - }); - test('tracks only the active VS Code renderer window', () => { assert.deepStrictEqual({ windowActive: [ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts index 53d0edfa92d91..a3ef48582a38d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts @@ -15,7 +15,7 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { SaveReason } from '../../../../../common/editor.js'; import { ISaveAllEditorsOptions, ISaveEditorsResult } from '../../../../../services/editor/common/editorService.js'; import { TestEditorService } from '../../../../../test/browser/workbenchTestServices.js'; -import { acceptAndAwaitSentRequest, ChatWidget, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome, shouldUnlockChatPetQueueOrSteeringMessage, shouldUnlockChatPetRequestRevision } from '../../../browser/widget/chatWidget.js'; +import { acceptAndAwaitSentRequest, ChatWidget, computeChatSessionStateIndicatorState, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome, shouldUnlockChatPetQueueOrSteeringMessage, shouldUnlockChatPetRequestRevision } from '../../../browser/widget/chatWidget.js'; import { IChatListItemTemplate } from '../../../browser/widget/chatListRenderer.js'; import { ChatRequestQueueKind, ChatSendResult, ChatSendResultSent, IChatSendRequestData } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../common/constants.js'; @@ -147,6 +147,44 @@ suite('ChatWidget', () => { ], [true, false]); }); + test('tracks unvisited completions and needs-input precedence', () => { + const active = computeChatSessionStateIndicatorState({ + requestNeedsInput: false, + requestInProgress: true, + containsFocus: false, + requestWasActive: false, + hasUnvisitedCompletion: false, + }); + const completed = computeChatSessionStateIndicatorState({ + requestNeedsInput: false, + requestInProgress: false, + containsFocus: false, + requestWasActive: active.requestActive, + hasUnvisitedCompletion: active.hasUnvisitedCompletion, + }); + const visited = computeChatSessionStateIndicatorState({ + requestNeedsInput: false, + requestInProgress: false, + containsFocus: true, + requestWasActive: completed.requestActive, + hasUnvisitedCompletion: completed.hasUnvisitedCompletion, + }); + const needsInput = computeChatSessionStateIndicatorState({ + requestNeedsInput: true, + requestInProgress: true, + containsFocus: true, + requestWasActive: visited.requestActive, + hasUnvisitedCompletion: visited.hasUnvisitedCompletion, + }); + + assert.deepStrictEqual({ active, completed, visited, needsInput }, { + active: { state: 'inProgress', requestActive: true, hasUnvisitedCompletion: false }, + completed: { state: 'idle', requestActive: false, hasUnvisitedCompletion: true }, + visited: { state: 'idle', requestActive: false, hasUnvisitedCompletion: false }, + needsInput: { state: 'needsInput', requestActive: true, hasUnvisitedCompletion: false }, + }); + }); + test('sticky request click survives synchronous template disposal during reveal', () => { const request = upcastPartial({ id: 'request', diff --git a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts index 614554fc065c6..30b241114224a 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts @@ -15,7 +15,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { HasSpeechProvider, ISpeechService, SpeechToTextInProgress, SpeechToTextStatus } from '../../../speech/common/speechService.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../chat/browser/speechToText/chatSpeechToTextService.js'; -import { activeDictationEditor, isDictating, startDictation, stopDictation } from '../../../chat/browser/speechToText/dictationSession.js'; +import { activeDictationEditor, cancelDictation, isDictating, startDictation, stopDictation } from '../../../chat/browser/speechToText/dictationSession.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; import { EditorAction2, EditorContributionInstantiation, registerEditorContribution } from '../../../../../editor/browser/editorExtensions.js'; @@ -87,7 +87,7 @@ export class EditorDictationStartAction extends EditorAction2 { }); } - override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void { + override async runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): Promise { const dictation = EditorDictation.get(editor); // Toggle: pressing the start keybinding again while dictation is in @@ -100,23 +100,24 @@ export class EditorDictationStartAction extends EditorAction2 { const keybindingService = accessor.get(IKeybindingService); const holdMode = keybindingService.enableKeybindingHoldMode(this.desc.id); - if (holdMode) { - let shouldCallStop = false; - - const handle = setTimeout(() => { - shouldCallStop = true; - }, 500); - - holdMode.finally(() => { - clearTimeout(handle); - - if (shouldCallStop) { - EditorDictation.get(editor)?.stop(); - } - }); + if (!holdMode) { + await dictation?.start(); + return; } - EditorDictation.get(editor)?.start(); + let shouldCallStop = false; + const handle = setTimeout(() => { + shouldCallStop = true; + }, 500); + try { + await dictation?.start(); + await holdMode; + } finally { + clearTimeout(handle); + } + if (shouldCallStop) { + EditorDictation.get(editor)?.stop(); + } } } @@ -244,7 +245,7 @@ export class EditorDictation extends Disposable implements IEditorContribution { /** True while a dictation session is active in this editor. */ isInProgress(): boolean { - return !!this.editorDictationInProgress.get(); + return !!this.editorDictationInProgress.get() || activeDictationEditor() === this.editor; } async start(): Promise { @@ -272,9 +273,6 @@ export class EditorDictation extends Disposable implements IEditorContribution { this.widget.active(); disposables.add(toDisposable(() => this.widget.hide())); - this.editorDictationInProgress.set(true); - disposables.add(toDisposable(() => this.editorDictationInProgress.reset())); - disposables.add(this.editor.onDidChangeCursorPosition(() => this.widget.layout())); const window = getWindow(this.editor.getDomNode()) ?? getActiveWindow(); @@ -287,6 +285,9 @@ export class EditorDictation extends Disposable implements IEditorContribution { return; } + this.editorDictationInProgress.set(true); + disposables.add(toDisposable(() => this.editorDictationInProgress.reset())); + // When the shared session ends on its own (final transcript applied, an // error, or the model failing to load), tear down the editor-side UI. This // is registered only after the takeover in `startDictation` has settled so @@ -391,6 +392,10 @@ export class EditorDictation extends Disposable implements IEditorContribution { // Built-in dictation into this editor is owned by the shared chat // dictation session; stop it there so the final transcript is applied. if (isDictating() && activeDictationEditor() === this.editor) { + if (this.chatSpeechToTextService.state === ChatSpeechToTextState.Idle) { + cancelDictation(); + return; + } stopDictation(); return; } diff --git a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts index f0e71c6870355..da233362800d4 100644 --- a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts @@ -13,6 +13,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { EnablementState, IWorkbenchExtensionEnablementService } from '../../../../services/extensionManagement/common/extensionManagement.js'; import { HasSpeechProvider, SpeechToTextInProgress } from '../../../speech/common/speechService.js'; import { IChatSpeechToTextService } from '../../../chat/browser/speechToText/chatSpeechToTextService.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { registerActiveInstanceAction, sharedWhenClause } from '../../../terminal/browser/terminalActions.js'; import { TerminalCommandId } from '../../../terminal/common/terminal.js'; import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; @@ -26,6 +27,10 @@ export function registerTerminalVoiceActions() { title: localize2('workbench.action.terminal.startDictation', "Start Dictation in Terminal"), category: VOICE_CATEGORY, precondition: ContextKeyExpr.and( + ContextKeyExpr.or( + HasSpeechProvider, + ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.speechToTextConfigured) + ), // Keep the toggle available for terminal dictation, but not unrelated speech-to-text sessions. ContextKeyExpr.or(SpeechToTextInProgress.toNegated(), TerminalContextKeys.terminalDictationInProgress), sharedWhenClause.terminalAvailable @@ -49,7 +54,7 @@ export function registerTerminalVoiceActions() { // on-device engine (preferred), or the speech extension's provider. if (chatSpeechToTextService.isConfigured || HasSpeechProvider.getValue(contextKeyService)) { const instantiationService = accessor.get(IInstantiationService); - TerminalVoiceSession.getInstance(instantiationService).start(); + await TerminalVoiceSession.getInstance(instantiationService).start(); return; } const extensions = await extensionManagementService.getInstalled(); diff --git a/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts b/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts index 701d532eb8634..b4d515285ecbd 100644 --- a/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts @@ -129,4 +129,23 @@ suite('TerminalVoiceSession', () => { assert.ok(sentTexts.some(t => t.includes('echo hello')), `expected dictated text to be sent, got ${JSON.stringify(sentTexts)}`); session.dispose(); }); + + test('falls back to the speech provider when built-in dictation is not configured', async () => { + let providerStarts = 0; + instantiationService.stub(ISpeechService, new class extends mock() { + override async createSpeechToTextSession() { + providerStarts++; + return { onDidChange: Event.None }; + } + }); + instantiationService.stub(IChatSpeechToTextService, new class extends mock() { + override readonly isConfigured = false; + }); + + const session = TerminalVoiceSession.getInstance(instantiationService as unknown as IInstantiationService); + await session.start(); + + assert.strictEqual(providerStarts, 1); + session.dispose(); + }); }); diff --git a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts index b60894a750254..69a37fc0a7357 100644 --- a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts +++ b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts @@ -250,7 +250,7 @@ export class TestResultsViewContent extends Disposable { public fillBody(containerElement: HTMLElement): void { const initialSpitWidth = TestResultsViewContent.lastSplitWidth; - this.splitView = new SplitView(containerElement, { orientation: Orientation.HORIZONTAL }); + this.splitView = this._register(new SplitView(containerElement, { orientation: Orientation.HORIZONTAL })); const { historyVisible, showRevealLocationOnMessages } = this.options; const isInPeekView = this.editor !== undefined; diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index 87c3210aeff0f..6e0c10447cb02 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -7,7 +7,7 @@ import { findFirstIdxMonotonousOrArrLen } from '../../../../base/common/arraysFi import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { createSingleCallFunction } from '../../../../base/common/functional.js'; -import { Disposable, DisposableStore, dispose, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -76,7 +76,7 @@ export class TestResultService extends Disposable implements ITestResultService declare _serviceBrand: undefined; private changeResultEmitter = this._register(new Emitter()); private _results: ITestResult[] = []; - private readonly _resultsDisposables: DisposableStore[] = []; + private readonly _resultsDisposables = this._register(new DisposableMap()); private testChangeEmitter = this._register(new Emitter()); private insertOrderCounter = 0; @@ -115,7 +115,6 @@ export class TestResultService extends Disposable implements ITestResultService @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); - this._register(toDisposable(() => dispose(this._resultsDisposables))); this.isRunning = TestingContextKeys.isRunning.bindTo(contextKeyService); this.hasAnyResults = TestingContextKeys.hasAnyResults.bindTo(contextKeyService); } @@ -181,18 +180,25 @@ export class TestResultService extends Disposable implements ITestResultService } this.hasAnyResults.set(true); + let removed: ITestResult | undefined; if (this.results.length > RETAIN_MAX_RESULTS) { - this.results.pop(); - this._resultsDisposables.pop()?.dispose(); + removed = this.results.pop(); } const ds = new DisposableStore(); - this._resultsDisposables.push(ds); + this._resultsDisposables.set(result, ds); if (result instanceof LiveTestResult) { ds.add(result); ds.add(result.onComplete(() => this.onComplete(result))); ds.add(result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter)); + } + + if (removed) { + this._resultsDisposables.deleteAndDispose(removed); + } + + if (result instanceof LiveTestResult) { this.isRunning.set(true); this.changeResultEmitter.fire({ started: result }); } else { @@ -237,6 +243,9 @@ export class TestResultService extends Disposable implements ITestResultService } this._results = keep; + for (const result of removed) { + this._resultsDisposables.deleteAndDispose(result); + } this.persistScheduler.schedule(); if (keep.length === 0) { this.hasAnyResults.set(false); diff --git a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts index 272d02a8a836f..4faad9ae2ceac 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -44,6 +44,8 @@ suite('Workbench - Test Results Service', () => { let insertCounter = 0; class TestLiveTestResult extends LiveTestResult { + public disposed = false; + constructor( id: string, persist: boolean, @@ -56,6 +58,11 @@ suite('Workbench - Test Results Service', () => { public setAllToStatePublic(state: TestResultState, taskId: string, when: (task: ITestTaskState, item: TestResultItem) => boolean) { this.setAllToState(state, taskId, when); } + + public override dispose(): void { + this.disposed = true; + super.dispose(); + } } const ds = ensureNoDisposablesAreLeakedInTestSuite(); @@ -272,6 +279,7 @@ suite('Workbench - Test Results Service', () => { results.clear(); assert.deepStrictEqual(results.results, [r2]); + assert.strictEqual(r.disposed, true); }); test('keeps ongoing tests on top, restored order when done', async () => { @@ -331,6 +339,19 @@ suite('Workbench - Test Results Service', () => { results.push(hydrated2); assert.deepStrictEqual(results.results, [r, hydrated1, hydrated2]); }); + + test('disposes a completed result that is immediately evicted', async () => { + const newerCompletedAt = Date.now() + 1000; + for (let i = 0; i < 128; i++) { + results.push(await makeHydrated(newerCompletedAt + i)); + } + + const older = new TestLiveTestResult('older', false, defaultOpts([])); + older.markComplete(); + results.push(older); + + assert.deepStrictEqual({ retained: results.results.includes(older), disposed: older.disposed }, { retained: false, disposed: true }); + }); }); test('resultItemParents', function () { diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 36b9667d6b400..23f0dbb85c1ae 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -273,6 +273,11 @@ interface IManagedSettingsSources { readonly file: ManagedSettingsData; } +interface IAuthenticatedRequestOptions { + readonly requestTimeoutMs?: number; + readonly retryNotFound?: boolean; +} + type DefaultAccountStatusTelemetry = { status: string; initial: boolean; @@ -695,7 +700,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private onManagedSettingsSourceChanged(): void { if (this.initialized) { - void this.updateDefaultAccount({ forceRefresh: true }); + void this.updateDefaultAccount(); } } @@ -914,15 +919,10 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private async findMatchingProviderSession(authProviderId: string, allScopes: string[][]): Promise { const sessions = await this.getSessions(authProviderId); - const matchingSessions = []; - for (const session of sessions) { + const matchingSessions = sessions.filter(session => { this.logService.debug('[DefaultAccount] Checking session with scopes', session.scopes); - for (const scopes of allScopes) { - if (this.scopesMatch(session.scopes, scopes)) { - matchingSessions.push(session); - } - } - } + return allScopes.some(scopes => this.scopesMatch(session.scopes, scopes)); + }); return matchingSessions.length > 0 ? matchingSessions : undefined; } @@ -1016,7 +1016,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private async getEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: IDefaultAccountRefreshOptions): Promise<{ data: IEntitlementsData | undefined | null; fetchedAt: number | undefined }> { const accountId = sessions[0].account.id; const existingData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; - if (!options?.forceRefresh && existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { + if (!options?.forceRefresh && !options?.refreshEntitlements && existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched entitlements data'); return { data: existingData, fetchedAt: accountPolicyData.entitlementsFetchedAt }; } @@ -1169,7 +1169,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const freshnessSatisfied = requirement.effective && isManagedSettingsFreshnessSatisfiedFor(this._managedSettingsFreshness, scope); // When forceRemoteSettingsRefresh is effective, reuse also requires this scope's freshness to be // satisfied; an outstanding compatibility error always forces revalidation. - if (!options?.forceRefresh && scopedCachedManagedSettings && (!requirement.effective || freshnessSatisfied) && !this._managedSettingsCompatibilityError) { + if (!options?.forceRefresh && !options?.retryManagedSettings && scopedCachedManagedSettings && (!requirement.effective || freshnessSatisfied) && !this._managedSettingsCompatibilityError) { this.logService.debug('[DefaultAccount] Using last fetched managed settings data'); return { ...scopedCachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; } @@ -1298,7 +1298,10 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const requestUrl = appendManagedSettingsClientIdentity(managedSettingsUrl, this.productService); this.logService.debug('[DefaultAccount] Fetching managed settings from:', requestUrl); const rateLimitBackoffActive = Date.now() < this._rateLimitBackoffUntil; - const response = await this.request(requestUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', MANAGED_SETTINGS_REQUEST_TIMEOUT_MS); + const response = await this.request(requestUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', { + requestTimeoutMs: MANAGED_SETTINGS_REQUEST_TIMEOUT_MS, + retryNotFound: false, + }); if (!response) { this.logService.debug('[DefaultAccount] Managed settings fetch returned no response (network error, all selected sessions rejected, or active rate-limit backoff); falling back to local-only policy'); this.reportManagedSettingsOutcome('no-response', rateLimitBackoffActive); @@ -1441,9 +1444,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private _rateLimitBackoffUntil = 0; - private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; - private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; - private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise { + private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise; + private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise; + private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise { if (Date.now() < this._rateLimitBackoffUntil) { const remainingSec = Math.ceil((this._rateLimitBackoffUntil - Date.now()) / 1000); this.logService.debug(`[DefaultAccount] Skipping request to ${url} — rate-limit backoff active for ${remainingSec}s more`); @@ -1463,7 +1466,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun url, data: type === 'POST' ? JSON.stringify(body) : undefined, disableCache: true, - timeout: requestTimeoutMs, + timeout: options?.requestTimeoutMs, headers: { 'Authorization': `Bearer ${session.accessToken}` }, @@ -1477,7 +1480,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.logService.warn(`[DefaultAccount] Rate limited by ${url} (status ${status}); backing off for ${retryAfterSec}s`); return response; } - if (status === 401 || status === 404) { + if (status === 401 || (status === 404 && options?.retryNotFound !== false)) { this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; continue; // try next session diff --git a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts index e1b3af95aadd6..95cabd8ef6bcc 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -112,6 +112,78 @@ suite('DefaultAccountProvider', () => { }); }); + test('entitlement refresh preserves satisfied governed managed settings', async () => { + const freshEntitlements = { + access_type_sku: 'copilot_business_seat', + chat_enabled: true, + assigned_date: '2026-01-01', + can_signup_for_limited: false, + copilot_plan: 'business', + organization_login_list: [], + analytics_tracking_id: 'tracking-id', + }; + const requestService = new TestRequestService(async options => options.callSite === 'defaultAccount.entitlements' + ? jsonResponse(freshEntitlements) + : Promise.reject(new Error(`Unexpected request: ${options.url}`))); + const provider = await createProvider(requestService); + const fetchedAt = Date.now() - 1000; + const managedSettingsScope = { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }; + const cachedPolicy = { + ...createCachedPolicy(true), + managedSettingsScope, + }; + const policyData = { + ...cachedPolicy, + entitlementsFetchedAt: fetchedAt, + managedSettingsFetchedAt: fetchedAt, + }; + provider['setDefaultAccount']({ + defaultAccount: { + authenticationProvider: { id: 'github', name: 'GitHub', enterprise: false }, + accountName: sessions[0].account.label, + sessionId: sessions[0].id, + enterprise: false, + entitlementsData: { ...freshEntitlements, copilot_plan: 'individual' }, + }, + accountId, + policyData, + copilotTokenInfo: null, + }); + provider['setManagedSettingsFreshness']({ + state: ManagedSettingsFreshnessState.Satisfied, + source: 'server', + scope: managedSettingsScope, + lastAttemptAt: fetchedAt, + satisfiedAt: fetchedAt, + }); + + const refreshedEntitlements = await provider['getDefaultAccountFromAuthenticatedSessions']( + { id: 'github', name: 'GitHub', enterprise: false }, + sessions, + { refreshEntitlements: true } + ); + + assert.deepStrictEqual({ + callSites: requestService.requests.map(request => request.callSite), + refreshedCopilotPlan: refreshedEntitlements?.defaultAccount.entitlementsData?.copilot_plan, + freshness: describeFreshness(provider.managedSettingsFreshness), + }, { + callSites: ['defaultAccount.entitlements'], + refreshedCopilotPlan: 'business', + freshness: { + state: ManagedSettingsFreshnessState.Satisfied, + source: 'server', + scope: managedSettingsScope, + hasLastAttempt: true, + hasSatisfiedAt: true, + }, + }); + }); + test('settings without a refresh requirement refetch only after the cache becomes stale', async () => { const requestService = new TestRequestService(async () => jsonResponse({})); const provider = await createProvider(requestService); @@ -278,6 +350,26 @@ suite('DefaultAccountProvider', () => { }); }); + test('managed settings 404 does not retry with another authentication session', async () => { + const requestService = new TestRequestService(async () => jsonResponse({}, 404)); + const provider = await createProvider(requestService); + + const result = await provider['getManagedSettings']([ + sessions[0], + { ...sessions[0], id: 'second-session' }, + ], undefined); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + status: provider.managedSettingsFetchStatus, + data: result.data, + }, { + requestCount: 1, + status: 404, + data: { managedSettings: undefined }, + }); + }); + test('fresh 404 satisfies a native refresh requirement', async () => { const requestService = new TestRequestService(async () => jsonResponse({}, 404)); const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); @@ -557,6 +649,28 @@ suite('DefaultAccountProvider', () => { assert.strictEqual(requestService.requestCount, 2); }); + test('matching authentication sessions are not duplicated by overlapping accepted scopes', async () => { + const broadSession: AuthenticationSession = { + ...sessions[0], + scopes: ['read:user', 'user:email', 'repo', 'workflow'], + }; + const provider = await createProvider( + new TestRequestService(async () => jsonResponse({})), + {}, + {}, + '', + { getSessions: async () => [broadSession] } + ); + + const matching = await provider['findMatchingProviderSession']('github', [ + ['read:user', 'user:email', 'repo', 'workflow'], + ['user:email'], + ['read:user'], + ]); + + assert.deepStrictEqual(matching?.map(session => session.id), ['session']); + }); + test('first server response can establish and satisfy a refresh requirement', async () => { const requestService = new TestRequestService(async () => jsonResponse({ forceRemoteSettingsRefresh: true, diff --git a/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts index ee296ecbd87db..ec71c03186362 100644 --- a/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts +++ b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts @@ -8,15 +8,18 @@ import { IChannel, ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js import { IPlaywrightService, IPlaywrightServiceInitializeOptions } from '../../../../platform/browserView/common/playwrightService.js'; import { registerSharedProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; class PlaywrightChannelClient { constructor( channel: IChannel, @ILogService logService: ILogService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { // Initialize the per-window shared-process service before forwarding calls. const options: IPlaywrightServiceInitializeOptions = { windowId: mainWindow.vscodeWindowId, + useSessionStorageAffinity: environmentService.isSessionsWindow, }; void channel.call('__initialize', options).catch((e) => { logService.error(`Failed to initialize Playwright service`, e); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 9d6f333acc8bf..c84b31cf634f7 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -1221,7 +1221,7 @@ export class ChatEntitlementRequests extends Disposable { } async forceResolveEntitlement(token = CancellationToken.None): Promise { - const defaultAccount = await this.defaultAccountService.refresh({ forceRefresh: true }); + const defaultAccount = await this.defaultAccountService.refresh({ refreshEntitlements: true }); if (!defaultAccount) { return undefined; } diff --git a/src/vs/workbench/services/telemetry/browser/telemetryService.ts b/src/vs/workbench/services/telemetry/browser/telemetryService.ts index 768fc0c30a391..daa2c00fdd13e 100644 --- a/src/vs/workbench/services/telemetry/browser/telemetryService.ts +++ b/src/vs/workbench/services/telemetry/browser/telemetryService.ts @@ -135,7 +135,7 @@ export class TelemetryService extends Disposable implements ITelemetryService { return this.impl.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this.impl.setCommonProperty(name, value); } diff --git a/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts b/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts index 3fb1c7c464082..98ab319d75ff4 100644 --- a/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts +++ b/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts @@ -101,7 +101,7 @@ export class TelemetryService extends Disposable implements ITelemetryService { return this.impl.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this.impl.setCommonProperty(name, value); } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 3adcf877df267..bf315b158554a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -73,6 +73,7 @@ import { AICustomizationManagementEditorInput } from '../../../../contrib/chat/b import { IConfigurationService, IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { IMcpGalleryManifestService, McpGalleryManifestStatus } from '../../../../../platform/mcp/common/mcpGalleryManifest.js'; import { McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; import { IAutomationDialogService } from '../../../../contrib/chat/common/automations/automationDialogService.js'; @@ -108,6 +109,15 @@ import '../../../../contrib/chat/browser/aiCustomization/media/aiCustomizationMa const userHome = URI.file('/home/dev'); const BUILTIN_STORAGE = 'builtin'; +function createMockMcpGalleryManifestService(): IMcpGalleryManifestService { + return new class extends mock() { + override readonly mcpGalleryManifestStatus = McpGalleryManifestStatus.Unavailable; + override readonly onDidChangeMcpGalleryManifestStatus = Event.None; + override readonly onDidChangeMcpGalleryManifest = Event.None; + override async getMcpGalleryManifest() { return null; } + }(); +} + interface IFixtureFile { readonly uri: URI; readonly storage: PromptsStorage; @@ -178,7 +188,9 @@ function createFixtureAgentHostItemProvider(files: readonly IFixtureFile[], remo name: file.name ?? '', description: file.description, source: file.storage as AICustomizationSource, - groupKey: file.type === PromptsType.skill && file.name === remoteClientSkillName ? 'remote-client' : 'remote-host', + groupKey: file.storage === PromptsStorage.builtIn + ? undefined + : file.type === PromptsType.skill && file.name === remoteClientSkillName ? 'remote-client' : 'remote-host', extensionId: file.extensionId, pluginUri: undefined, })); @@ -816,6 +828,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor [ChatConfiguration.ChatCustomizationsUserDataMigrationEnabled]: true, })); reg.define(IListService, ListService); + reg.defineInstance(IMcpGalleryManifestService, createMockMcpGalleryManifestService()); reg.defineInstance(ITextModelService, new class extends mock() { declare readonly _serviceBrand: undefined; override async createModelReference(resource: URI): Promise> { @@ -1157,6 +1170,8 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor if (options.scrollToBottom) { editor.revealLastItem(); + // Allow the 500ms hide delay and 800ms fade transition to complete. + await new Promise(resolve => setTimeout(resolve, 1400)); } if (options.migrationCategory) { @@ -1228,6 +1243,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise additionalServices: (reg) => { registerWorkbenchServices(reg); reg.define(IListService, ListService); + reg.defineInstance(IMcpGalleryManifestService, createMockMcpGalleryManifestService()); reg.defineInstance(IMcpWorkbenchService, new class extends mock() { override readonly onChange = Event.None; override readonly onReset = Event.None; @@ -1501,6 +1517,7 @@ function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): voi additionalServices: (reg) => { registerWorkbenchServices(reg); reg.define(IListService, ListService); + reg.defineInstance(IMcpGalleryManifestService, createMockMcpGalleryManifestService()); reg.defineInstance(IConfigurationService, createDisabledConfigService(mcpAccessConfig, McpAccessValue.None, byPolicy)); reg.defineInstance(IMcpWorkbenchService, new class extends mock() { override readonly onChange = Event.None; @@ -1888,7 +1905,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { render: ctx => renderEditor(ctx, { sessionResource: agentHostCopilotSessionResource, selectedSection: AICustomizationManagementSection.Skills, - agentHostFiles: allFiles.filter(file => file.type !== PromptsType.skill || file.name === 'Accessibility' || file.name === 'Code Review'), + agentHostFiles: allFiles.filter(file => file.type !== PromptsType.skill || file.name === 'Accessibility' || file.name === 'Code Review' || file.storage === PromptsStorage.builtIn), remoteClientSkillName: 'Code Review', height: 800, }), @@ -2121,6 +2138,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Scrolled-to-bottom variants — verify last items are fully visible above footer PromptsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, + virtualTime: { durationMs: 1500 }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Prompts, @@ -2130,6 +2148,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, + virtualTime: { durationMs: 1500 }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, @@ -2139,6 +2158,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, + virtualTime: { durationMs: 1500 }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, @@ -2285,6 +2305,12 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { })), }), + // Standalone embedded MCP detail widget before marketplace installation. + EmbeddedMcpDetailUninstalled: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEmbeddedMcpDetail(ctx, galleryServers[0]), + }), + // Standalone embedded MCP detail widget — empty / no input state. EmbeddedMcpDetailEmpty: defineComponentFixture({ labels: { kind: 'screenshot' }, diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 8687b470258d2..0e7c987b6e402 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -7,70 +7,76 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/c13f82b9dbe63bd2450bb28f48d66704613bfa02a36b47c6a05a3c9b4d0b81ea) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6c26540ad074f1bb302f82ce39f5d1cce3478679bd385cc0e5b68c3b7d48c1c7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e268b39e5d6e416b04ef4d38c0bd5af7d4c2fbd65fccf525cdd39bb4c0a8b677) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a90405bc9ec5b34569c77e3d180f79b847afe6daa4668a72d158b9787cfc55bc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/08799bed239d1dda65f4faf6a038c606dba9c4c24946271440f2c931da42da3d) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d51c5ae97416ae8983026c9436150ba766b2837d8794c113ab5891665df74561) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a83a9d7e75afeb186484f56b4ac280426db67149a9f4678090ed9b9e2a707ea) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e652f2a9309c421bf0a7c805160521ad2837e320fdec9e00393ff981a2eac014) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b84b57c9751c608e2add0c47891dfa443036d3b72e8d8e090b51bd7230f1aaae) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5c4c329b3df33c748b8cd46ba7585490b58b2d42fa71c25a44a7f327b344b1b) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b150bed8ecf034a2ac61bc46081007101d109ed98bc176824e045965dfff94da) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/34ca750e1747a1e89fbeaab3b755d1372ee503e009f4c2a32a4d7205742b3dce) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bfdfa45f49cf147c63733f42db4f09bc28c5ea17c8f56d39a8e02d1855813be5) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/0a8c7e47a3dadf698e121c5bca782d5235ba9cfb205bfa1362307d4fea2e56f6) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/35ef986c0f72d6682a4fe7e690aab7e3d5a93409c0f030494b2fc23928b05644) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/60660b23153246506d9710d0239c15292079b357def9bbc8d7f92b8184c058c4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/381816517a26c12e647f06d53e86100e1c67445bb76ea60ee3dcd981ffeaa41d) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a424b83224188683990741c5a50163601605069c5809ae8e60c01dbee749000) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b8c214bd7418237091cedd35cb8e5abb117e9b0741c8c108000939306595e0ec) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/818485b76cf89c4cd8951e5819820d764dbcb11505aa0c302cfe757b95981acd) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/74532b70cb20bf39b0b56f444c4bf5edd86cce9ba7716f023cf90f39e9654dd8) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9733bdf2e83f6c6442db4d1ced4634a93d8a292d78ae101cf263fea76f64053c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ca8da65e09bb1c6a09530522e70dd51d18ca870fa13f25506d0608a5505f04a2) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/52daa14b7b61fd8ea7a28f0db9acaa65dbd403a85078c03cc31c0c135101c236) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a35248fe00e671dfa1613970c3d5c5c1cd8d898dfee4526f3595367fbb7cca81) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/94410e4c60912cfa7c8e58096d69c1f3e5d6dde2c5eb4ae17c356d29147c1850) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bbeff84bd7f12045e3033d3df8b2f6e2d4feb30f1d02b3f92d38425fe6078b63) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/11be24ce9bdb02eaa809b871ba506d469a745e42f8f11250903ddb6084f721db) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e0b4dd4aafce49b83131fb5fcbc5458052695d5e9382d014edc411464527ca2a) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d451310e9fce1333215b6be7d258c5fc131a624a41198251d18b75e37d83d23e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8ba7ed9a14433d46c8aed5d7a5cc05047d1e9e18c2916be87c02b9a4cd1c73eb) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/84c453d09dae5b3b87205364370e55360812a425dda96123659ac15402c5dcb4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a30e32dae4d629b88694f3b482add3269620ad0ffad0c310056390bab3efb604) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/669e7d7c8d96c2076c8a576fa3a7e5c5cecf1f95671a675162eefad7a5ff3e11) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4353419eb708c0d7ff8fe8a01a8ecb53c231020e7397995f390bca1f172129a0) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e2d667dd5052de6eca13f0c07f7b4dfa768a4499cdb10885c6e3bb75b636324f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a49fc7f166a304ee372ccca3cd6e9e26688b0c80e1ec69fd734361b0221eee16) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b1001378286b10e85c5ea0d38a2a39b781a2370e07f686182ac0f7f448f7c0b3) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9fcc38b4e24fdfa58c30408dbdaf6d0054cc173dcf9e123d23112149be4f6ea1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e2d667dd5052de6eca13f0c07f7b4dfa768a4499cdb10885c6e3bb75b636324f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a49fc7f166a304ee372ccca3cd6e9e26688b0c80e1ec69fd734361b0221eee16) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b1001378286b10e85c5ea0d38a2a39b781a2370e07f686182ac0f7f448f7c0b3) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9fcc38b4e24fdfa58c30408dbdaf6d0054cc173dcf9e123d23112149be4f6ea1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/71c963b2ea1414b83ed3ad4da0c556197369d23d7f0b63806d0e2bf9f9b04127) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/498dd59bd46769e3ca7393f202f587789c17a2979b87ca378f20e37f333bdfd4) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/683eab172739819c9bac2d664ded814f40824de289ca4c3570b6d968bb04070c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f08f056b5a9843fc413b1105d5ede83a19961aa8d000158779068b94c72135d5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/1d6f85fdd57fe38f105b261d64f2b3f54acd3cadf29d05ed216885dc7aadf30d) @@ -79,10 +85,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a8929c0724d59231dc954fda808ef68226a5e78f09b66ea279e6150c3c39650c) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/1e100b00beb07383f488c73fcc79c855b4098ae24e46db01072f2be1ea954d88) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/afe68d55d548d3234536e2e69147f8c48de5dac0e1eb09b1ff656337c370d664) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/43638fae9595c059725b43c1dd8db047e9f521c1f32e8bb06a7768c6d451ab36) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4f4bf2ba517c50d456a557fb27ff423cce77e24adf626754af2cb055dd931761) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b6806a0c2bed06ad703fdc1f42eb2ca50d8cc3027d9878f05905b276999f3568) @@ -181,28 +187,28 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/1d76cd2c4bda9bed2203215ccd84ef0644895a8eddd5efc5934520dce3f5fce8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/62ff8ab266d68ed5dc3f46d122cc05f67c1580222b0eeb3c83d7baea6c9e0937) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe415859b934c657863844d2bfbf30da4ae84a2b167301ba2517d317588c6ed8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/41ea93d7262689955020b1eced4f7c7b8b423bd592bb29fba739d1b982b67a17) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5e62a510e536d64b4d76dc940a3ad0b744caa7c3a58111492be9f20ea8db801) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7228d732b817fa0201537ab73726c055a32e82ec4744abf94122e1b5b9f6054f) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/55683f61601d660f2f94c5c7a24179d782a0776464fa2680c5e00fa7b6fb2ee8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a4875725e56c9aea1ebd8aebdc8106fe955b9d89b3dc7115cadf290792590f0a) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8c86c66d82f1ef4e19e918463bfb5827167a22e238d81f66d1e48870862fb229) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/34408877e1ac8af8668377237c835da520bfc4a1301ebd55f6dab32084f88042) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a96db096792e9d7f75306eaa04c7e89e26a39870871b8480cc39fb5cf2cfe71) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ef4a895e36499772c251ee00d72d765d084b5082430e430c99398b7a12761c7a) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e9cbba25dc68f5f66bf85428b133461f2d1ebc236b1d6f82d08ae05aab9c33c7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/eaf71f4c733c6a7dc5c7bf9c48578243c6eb271769bb40593e8df5b977eb6111) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c51cbace6b7e770064231bb3aac07f91ad41e4fcf4d5bd6aedfbdb00c775fa45) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/79d4608ac44ba3bf43cb4555a8e02fca4165866a2fb6b518d3c3994b9228e37c) #### sessions/sessionsList/SessionsList_NestedChatHierarchyGuides/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/ae6689aace7015963b6fc3812c77019921e63523f97f630afde63579729077f2)