From 0540db443528f8e2de86036d23055b4fbfb02947 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Fri, 31 Jul 2026 10:39:49 -0700 Subject: [PATCH 01/29] Agent Host changes for agents/investigate-unit-tests-issue-reproduction --- .../platform/endpoint/node/chatEndpoint.ts | 15 ++- .../node/test/copilotChatEndpoint.spec.ts | 101 ++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index c542b994fa897..c8d06aeac4414 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -29,7 +29,7 @@ import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/t import { TelemetryData } from '../../telemetry/common/telemetryData'; import { ITokenizerProvider } from '../../tokenizer/node/tokenizer'; import { ICAPIClientService } from '../common/capiClient'; -import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, isKimiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; +import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, isGpt5PlusFamily, isKimiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; import { IDomainService } from '../common/domainService'; import { CustomModel, IChatModelInformation, ModelSupportedEndpoint } from '../common/endpointProvider'; import { normalizeTokenPrices } from '../../../extension/conversation/common/languageModelAccess'; @@ -322,7 +322,7 @@ export class ChatEndpoint implements IChatEndpoint { return this.modelMetadata.warning_messages?.at(0)?.message; } - public get apiType(): string { + public get apiType(): 'responses' | 'messages' | 'chatCompletions' { return this.useResponsesApi ? 'responses' : this.useMessagesApi ? 'messages' : 'chatCompletions'; } @@ -339,6 +339,17 @@ export class ChatEndpoint implements IChatEndpoint { body.stream = false; } + if ( + body?.max_tokens !== undefined + && this.customModel + && this.modelMetadata.capabilities.supports.thinking + && isGpt5PlusFamily(this) + && this.apiType === 'chatCompletions' + ) { + body.max_completion_tokens = body.max_tokens; + delete body.max_tokens; + } + // If it's o1 we must modify the body significantly as the request is very different if (body?.messages && (this.family.startsWith('o1') || this.model === CHAT_MODEL.O1 || this.model === CHAT_MODEL.O1MINI)) { const newMessages: CAPIChatMessage[] = body.messages.map((message: CAPIChatMessage): CAPIChatMessage => { diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index 50238a3436f13..7a3b3908f3b73 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -241,6 +241,107 @@ describe('CopilotChatEndpoint - Reasoning Properties', () => { }); }); +describe('CopilotChatEndpoint - enterprise custom model token parameter (#328418)', () => { + let mockServices: ReturnType; + + beforeEach(() => { + mockServices = createMockServices(); + }); + + const createEndpoint = (modelId: string, family: string, displayName: string, customModel = true, thinking = true) => { + const baseMetadata = createNonAnthropicModelMetadata(family); + const modelMetadata: IChatModelInformation = { + ...baseMetadata, + id: modelId, + name: displayName, + supported_endpoints: [ModelSupportedEndpoint.ChatCompletions], + custom_model: customModel ? { + key_name: modelId, + owner_name: 'enterprise' + } : undefined, + capabilities: { + ...baseMetadata.capabilities, + supports: { + ...baseMetadata.capabilities.supports, + thinking + }, + limits: { + max_prompt_tokens: 256000, + max_output_tokens: 256000, + max_context_window_tokens: 256000 + } + } + }; + + return new CopilotChatEndpoint( + modelMetadata, + mockServices.domainService, + mockServices.capiClientService, + mockServices.fetcherService, + mockServices.envService, + mockServices.telemetryService, + mockServices.authService, + mockServices.chatMLFetcher, + mockServices.tokenizerProvider, + mockServices.instantiationService, + mockServices.configurationService, + mockServices.expService, + mockServices.chatWebSocketService, + mockServices.logService + ); + }; + + it.each([ + { modelId: 'mbe_agent_gpt5_1_oai', family: 'gpt-5.1', displayName: 'mbe-gpt5.1-oai' }, + { modelId: 'mbe_agent_gpt5_4_oai', family: 'gpt-5.4', displayName: 'mbe-gpt5.4-oai' }, + { modelId: 'mbe_agent_gpt5_6_terra_oai', family: 'gpt-5.6-terra', displayName: 'mbe-gpt5.6-terra-oai' } + ])('sends max_completion_tokens to $displayName', ({ modelId, family, displayName }) => { + const endpoint = createEndpoint(modelId, family, displayName); + const body = endpoint.createRequestBody({ + ...createTestOptions([{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hi' }] + }]), + postOptions: { max_tokens: 256000 } + }); + + endpoint.interceptBody(body); + + expect({ + max_tokens: body.max_tokens, + max_completion_tokens: body.max_completion_tokens + }).toEqual({ + max_tokens: undefined, + max_completion_tokens: 256000 + }); + }); + + it.each([ + { label: 'built-in GPT-5 thinking model', endpoint: () => createEndpoint('gpt-5.4', 'gpt-5.4', 'GPT-5.4', false) }, + { label: 'enterprise GPT-5 non-thinking model', endpoint: () => createEndpoint('custom-gpt-5.4', 'gpt-5.4', 'Custom GPT-5.4', true, false) }, + { label: 'enterprise non-GPT thinking model', endpoint: () => createEndpoint('custom-claude', 'claude-sonnet-4', 'Custom Claude') } + ])('preserves max_tokens for $label', ({ endpoint: createEndpoint }) => { + const endpoint = createEndpoint(); + const body = endpoint.createRequestBody({ + ...createTestOptions([{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hi' }] + }]), + postOptions: { max_tokens: 4096 } + }); + + endpoint.interceptBody(body); + + expect({ + max_tokens: body.max_tokens, + max_completion_tokens: body.max_completion_tokens + }).toEqual({ + max_tokens: 4096, + max_completion_tokens: undefined + }); + }); +}); + describe('ChatEndpoint - Image Count Validation', () => { let mockServices: ReturnType; From 7ceb90a720ce580df5a7a69669797ce7f2803f13 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 4 Aug 2026 13:54:11 -0700 Subject: [PATCH 02/29] chat: fix custom model Chat Completions token limit Default server-provided custom Chat Completions models to max_completion_tokens, with an advanced experiment-based compatibility override for endpoints that still require max_tokens. Fixes #328418.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 17 +++++ extensions/copilot/package.nls.json | 3 + .../common/configurationService.ts | 3 +- .../platform/endpoint/node/chatEndpoint.ts | 21 +++--- .../node/test/copilotChatEndpoint.spec.ts | 71 ++++++++++++++----- 5 files changed, 88 insertions(+), 27 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 31145cc68f84a..b1a374ddf1082 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3542,6 +3542,23 @@ "onExp" ] }, + "github.copilot.chat.advanced.chatCompletionsTokenParameter": { + "type": "string", + "enum": [ + "max_completion_tokens", + "max_tokens" + ], + "enumDescriptions": [ + "%github.copilot.config.chatCompletionsTokenParameter.maxCompletionTokens%", + "%github.copilot.config.chatCompletionsTokenParameter.maxTokens%" + ], + "default": "max_completion_tokens", + "markdownDescription": "%github.copilot.config.chatCompletionsTokenParameter%", + "tags": [ + "advanced", + "onExp" + ] + }, "github.copilot.chat.imageUpload.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 687d1f2faa3b3..17a430a6bb7be 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -277,6 +277,9 @@ "github.copilot.tools.githubTextSearch.userDescription": "Text search a GitHub repository or organization for files containing specific keywords or code patterns.", "github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.", "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", + "github.copilot.config.chatCompletionsTokenParameter": "Controls the output token limit parameter sent to Chat Completions APIs. Use `max_tokens` only for compatibility with endpoints that do not support `max_completion_tokens`.", + "github.copilot.config.chatCompletionsTokenParameter.maxCompletionTokens": "Send `max_completion_tokens`.", + "github.copilot.config.chatCompletionsTokenParameter.maxTokens": "Send the legacy `max_tokens` parameter for compatibility.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'grep'.", "github.copilot.chat.tools.grepSearch.defaultMaxResults": "The default maximum number of results to return from the grep search tool. The default is 100.", diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 9b819f826185c..93231f49cc596 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -23,7 +23,7 @@ import { ResponseProcessor } from '../../inlineEdits/common/responseProcessor'; import { FetcherId } from '../../networking/common/fetcherService'; import { AlternativeNotebookFormat } from '../../notebook/common/alternativeContentFormat'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; -import { IValidator, vBoolean, vNumber, vString } from './validator'; +import { IValidator, vBoolean, vEnum, vNumber, vString } from './validator'; export const CopilotConfigPrefix = 'github.copilot'; @@ -746,6 +746,7 @@ export namespace ConfigKey { /** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */ export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null); + export const ChatCompletionsTokenParameter = defineSetting('chat.advanced.chatCompletionsTokenParameter', ConfigType.ExperimentBased, 'max_completion_tokens', vEnum('max_completion_tokens', 'max_tokens')); /** * When enabled, periodic keep-alive probes are sent during long-running tool calls diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index c8d06aeac4414..7835a12b1485d 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -29,7 +29,7 @@ import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/t import { TelemetryData } from '../../telemetry/common/telemetryData'; import { ITokenizerProvider } from '../../tokenizer/node/tokenizer'; import { ICAPIClientService } from '../common/capiClient'; -import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, isGpt5PlusFamily, isKimiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; +import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, isKimiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; import { IDomainService } from '../common/domainService'; import { CustomModel, IChatModelInformation, ModelSupportedEndpoint } from '../common/endpointProvider'; import { normalizeTokenPrices } from '../../../extension/conversation/common/languageModelAccess'; @@ -339,15 +339,16 @@ export class ChatEndpoint implements IChatEndpoint { body.stream = false; } - if ( - body?.max_tokens !== undefined - && this.customModel - && this.modelMetadata.capabilities.supports.thinking - && isGpt5PlusFamily(this) - && this.apiType === 'chatCompletions' - ) { - body.max_completion_tokens = body.max_tokens; - delete body.max_tokens; + if (body && this.customModel && this.apiType === 'chatCompletions') { + // Server-provided custom-model metadata does not reliably identify the underlying OpenAI-compatible provider. + const tokenParameter = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ChatCompletionsTokenParameter, this._expService); + if (tokenParameter === 'max_tokens' && body.max_completion_tokens !== undefined) { + body.max_tokens = body.max_completion_tokens; + delete body.max_completion_tokens; + } else if (tokenParameter === 'max_completion_tokens' && body.max_tokens !== undefined) { + body.max_completion_tokens = body.max_tokens; + delete body.max_tokens; + } } // If it's o1 we must modify the body significantly as the request is very different diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index 7a3b3908f3b73..6fae9295a25df 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -18,7 +18,7 @@ import { IChatModelInformation, ModelSupportedEndpoint } from '../../../endpoint import { IEnvService } from '../../../env/common/envService'; import { ILogService } from '../../../log/common/logService'; import { IFetcherService } from '../../../networking/common/fetcherService'; -import { ICreateEndpointBodyOptions } from '../../../networking/common/networking'; +import { ICreateEndpointBodyOptions, IEndpointBody } from '../../../networking/common/networking'; import { CAPIChatMessage } from '../../../networking/common/openai'; import { IChatWebSocketManager } from '../../../networking/node/chatWebSocketManager'; import { NullExperimentationService } from '../../../telemetry/common/nullExperimentationService'; @@ -241,23 +241,23 @@ describe('CopilotChatEndpoint - Reasoning Properties', () => { }); }); -describe('CopilotChatEndpoint - enterprise custom model token parameter (#328418)', () => { +describe('CopilotChatEndpoint - Chat Completions token parameter (#328418)', () => { let mockServices: ReturnType; beforeEach(() => { mockServices = createMockServices(); }); - const createEndpoint = (modelId: string, family: string, displayName: string, customModel = true, thinking = true) => { + const createEndpoint = (modelId: string, family: string, displayName: string, customModel = true, thinking = true, supportedEndpoints = [ModelSupportedEndpoint.ChatCompletions]) => { const baseMetadata = createNonAnthropicModelMetadata(family); const modelMetadata: IChatModelInformation = { ...baseMetadata, id: modelId, name: displayName, - supported_endpoints: [ModelSupportedEndpoint.ChatCompletions], + supported_endpoints: supportedEndpoints, custom_model: customModel ? { key_name: modelId, - owner_name: 'enterprise' + owner_name: 'organization' } : undefined, capabilities: { ...baseMetadata.capabilities, @@ -292,11 +292,10 @@ describe('CopilotChatEndpoint - enterprise custom model token parameter (#328418 }; it.each([ - { modelId: 'mbe_agent_gpt5_1_oai', family: 'gpt-5.1', displayName: 'mbe-gpt5.1-oai' }, - { modelId: 'mbe_agent_gpt5_4_oai', family: 'gpt-5.4', displayName: 'mbe-gpt5.4-oai' }, - { modelId: 'mbe_agent_gpt5_6_terra_oai', family: 'gpt-5.6-terra', displayName: 'mbe-gpt5.6-terra-oai' } - ])('sends max_completion_tokens to $displayName', ({ modelId, family, displayName }) => { - const endpoint = createEndpoint(modelId, family, displayName); + { modelId: 'mbe_agent_gpt5_4_oai', family: 'gpt-5.4', displayName: 'custom GPT-5 model', customModel: true }, + { modelId: 'custom-claude', family: 'claude-sonnet-4', displayName: 'custom non-GPT model', customModel: true } + ])('sends max_completion_tokens by default for $displayName', ({ modelId, family, displayName, customModel }) => { + const endpoint = createEndpoint(modelId, family, displayName, customModel); const body = endpoint.createRequestBody({ ...createTestOptions([{ role: Raw.ChatRole.User, @@ -316,12 +315,22 @@ describe('CopilotChatEndpoint - enterprise custom model token parameter (#328418 }); }); - it.each([ - { label: 'built-in GPT-5 thinking model', endpoint: () => createEndpoint('gpt-5.4', 'gpt-5.4', 'GPT-5.4', false) }, - { label: 'enterprise GPT-5 non-thinking model', endpoint: () => createEndpoint('custom-gpt-5.4', 'gpt-5.4', 'Custom GPT-5.4', true, false) }, - { label: 'enterprise non-GPT thinking model', endpoint: () => createEndpoint('custom-claude', 'claude-sonnet-4', 'Custom Claude') } - ])('preserves max_tokens for $label', ({ endpoint: createEndpoint }) => { - const endpoint = createEndpoint(); + it('preserves max_tokens for built-in Chat Completions models', () => { + const endpoint = createEndpoint('gpt-5.4', 'gpt-5.4', 'Built-in GPT-5 Model', false); + const body: IEndpointBody = { + max_tokens: 4096 + }; + + endpoint.interceptBody(body); + + expect(body).toEqual({ + max_tokens: 4096 + }); + }); + + it('sends max_tokens when configured for compatibility', () => { + mockServices.configurationService.setConfig(ConfigKey.Advanced.ChatCompletionsTokenParameter, 'max_tokens'); + const endpoint = createEndpoint('custom-model', 'custom-family', 'Custom Model'); const body = endpoint.createRequestBody({ ...createTestOptions([{ role: Raw.ChatRole.User, @@ -340,6 +349,36 @@ describe('CopilotChatEndpoint - enterprise custom model token parameter (#328418 max_completion_tokens: undefined }); }); + + it('replaces an explicitly provided max_completion_tokens with max_tokens when configured for compatibility', () => { + mockServices.configurationService.setConfig(ConfigKey.Advanced.ChatCompletionsTokenParameter, 'max_tokens'); + const endpoint = createEndpoint('custom-model', 'custom-family', 'Custom Model'); + const body: IEndpointBody = { + max_completion_tokens: 4096 + }; + + endpoint.interceptBody(body); + + expect(body).toEqual({ + max_tokens: 4096 + }); + }); + + it.each([ + { apiType: 'responses', supportedEndpoints: [ModelSupportedEndpoint.Responses] }, + { apiType: 'messages', supportedEndpoints: [ModelSupportedEndpoint.Messages] } + ])('does not change max_tokens for the $apiType API', ({ supportedEndpoints }) => { + const endpoint = createEndpoint('custom-model', 'custom-family', 'Custom Model', true, true, supportedEndpoints); + const body: IEndpointBody = { + max_tokens: 4096 + }; + + endpoint.interceptBody(body); + + expect(body).toEqual({ + max_tokens: 4096 + }); + }); }); describe('ChatEndpoint - Image Count Validation', () => { From 0db2883b12756a68949915796765653fc912aab2 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 4 Aug 2026 14:20:47 -0700 Subject: [PATCH 03/29] chat: fix token parameter setting registration Move the experiment-based compatibility setting into the advanced manifest section while keeping its public setting ID free of the internal .advanced. prefix.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 34 +++++++++---------- .../common/configurationService.ts | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 309256f885395..164a5c14b487b 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3542,23 +3542,6 @@ "onExp" ] }, - "github.copilot.chat.advanced.chatCompletionsTokenParameter": { - "type": "string", - "enum": [ - "max_completion_tokens", - "max_tokens" - ], - "enumDescriptions": [ - "%github.copilot.config.chatCompletionsTokenParameter.maxCompletionTokens%", - "%github.copilot.config.chatCompletionsTokenParameter.maxTokens%" - ], - "default": "max_completion_tokens", - "markdownDescription": "%github.copilot.config.chatCompletionsTokenParameter%", - "tags": [ - "advanced", - "onExp" - ] - }, "github.copilot.chat.imageUpload.enabled": { "type": "boolean", "default": true, @@ -4456,6 +4439,23 @@ { "id": "advanced", "properties": { + "github.copilot.chat.chatCompletionsTokenParameter": { + "type": "string", + "enum": [ + "max_completion_tokens", + "max_tokens" + ], + "enumDescriptions": [ + "%github.copilot.config.chatCompletionsTokenParameter.maxCompletionTokens%", + "%github.copilot.config.chatCompletionsTokenParameter.maxTokens%" + ], + "default": "max_completion_tokens", + "markdownDescription": "%github.copilot.config.chatCompletionsTokenParameter%", + "tags": [ + "advanced", + "onExp" + ] + }, "github.copilot.chat.inlineEdits.xtabProvider.modelConfiguration": { "type": [ "object", diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 5c994d14224d0..8d6a9af2c31cc 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -746,7 +746,7 @@ export namespace ConfigKey { /** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */ export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null); - export const ChatCompletionsTokenParameter = defineSetting('chat.advanced.chatCompletionsTokenParameter', ConfigType.ExperimentBased, 'max_completion_tokens', vEnum('max_completion_tokens', 'max_tokens')); + export const ChatCompletionsTokenParameter = defineSetting('chat.chatCompletionsTokenParameter', ConfigType.ExperimentBased, 'max_completion_tokens', vEnum('max_completion_tokens', 'max_tokens')); /** * When enabled, periodic keep-alive probes are sent during long-running tool calls From 1626b65ea7330e574f455f298347f3063ba7c7db Mon Sep 17 00:00:00 2001 From: "zainnadeem(RedOpsCell)" Date: Thu, 20 Aug 2026 10:49:19 +0500 Subject: [PATCH 04/29] Fix PowerShell quoting for runInTerminal env values --- .../workbench/contrib/debug/node/terminals.ts | 2 +- .../contrib/debug/test/node/terminals.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/debug/node/terminals.ts b/src/vs/workbench/contrib/debug/node/terminals.ts index b3b5a84c41129..91177b7ec46d3 100644 --- a/src/vs/workbench/contrib/debug/node/terminals.ts +++ b/src/vs/workbench/contrib/debug/node/terminals.ts @@ -103,7 +103,7 @@ export function prepareCommand(shell: string, args: string[], argsCanBeInterpret if (value === null) { command += `Remove-Item env:${key}; `; } else { - command += `\${env:${key}}='${value}'; `; + command += `\${env:${key}}=${quote(value)}; `; } } } diff --git a/src/vs/workbench/contrib/debug/test/node/terminals.test.ts b/src/vs/workbench/contrib/debug/test/node/terminals.test.ts index ea75628214281..72772076ad829 100644 --- a/src/vs/workbench/contrib/debug/test/node/terminals.test.ts +++ b/src/vs/workbench/contrib/debug/test/node/terminals.test.ts @@ -118,4 +118,22 @@ suite('Debug - prepareCommand', () => { prepareCommand('powershell', ['arg1', '>', '> hello.txt', '<', ' '> hello.txt' < ' { + assert.deepStrictEqual( + [ + prepareCommand('powershell', [], false, undefined, { SIMPLE: 'hello' }).trim(), + prepareCommand('powershell', [], false, undefined, { SPACES: 'hello world' }).trim(), + prepareCommand('powershell', [], false, undefined, { EMPTY: '' }).trim(), + prepareCommand('powershell', [], false, undefined, { QUOTE: 'hello\'world' }).trim(), + prepareCommand('powershell', [], false, undefined, { MULTI: 'it\'s \'ok\'' }).trim(), + ], + [ + '${env:SIMPLE}=\'hello\';', + '${env:SPACES}=\'hello world\';', + '${env:EMPTY}=\'\';', + '${env:QUOTE}=\'hello\'\'world\';', + '${env:MULTI}=\'it\'\'s \'\'ok\'\'\';', + ]); + }); }); From 9e09ac5902d37f046e3dccb0a3ee77e011087efe Mon Sep 17 00:00:00 2001 From: "zainnadeem(RedOpsCell)" Date: Thu, 20 Aug 2026 11:12:12 +0500 Subject: [PATCH 05/29] Preserve PowerShell env values when quoting --- src/vs/workbench/contrib/debug/node/terminals.ts | 2 +- src/vs/workbench/contrib/debug/test/node/terminals.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/debug/node/terminals.ts b/src/vs/workbench/contrib/debug/node/terminals.ts index 91177b7ec46d3..8f7ddfa1602a1 100644 --- a/src/vs/workbench/contrib/debug/node/terminals.ts +++ b/src/vs/workbench/contrib/debug/node/terminals.ts @@ -103,7 +103,7 @@ export function prepareCommand(shell: string, args: string[], argsCanBeInterpret if (value === null) { command += `Remove-Item env:${key}; `; } else { - command += `\${env:${key}}=${quote(value)}; `; + command += `\${env:${key}}='${value.replace(/'/g, '\'\'')}'; `; } } } diff --git a/src/vs/workbench/contrib/debug/test/node/terminals.test.ts b/src/vs/workbench/contrib/debug/test/node/terminals.test.ts index 72772076ad829..80d7cd616c85f 100644 --- a/src/vs/workbench/contrib/debug/test/node/terminals.test.ts +++ b/src/vs/workbench/contrib/debug/test/node/terminals.test.ts @@ -127,6 +127,8 @@ suite('Debug - prepareCommand', () => { prepareCommand('powershell', [], false, undefined, { EMPTY: '' }).trim(), prepareCommand('powershell', [], false, undefined, { QUOTE: 'hello\'world' }).trim(), prepareCommand('powershell', [], false, undefined, { MULTI: 'it\'s \'ok\'' }).trim(), + prepareCommand('powershell', [], false, undefined, { TRAILING: 'C:\\work\\' }).trim(), + prepareCommand('powershell', [], false, undefined, { BACKSLASH_QUOTE: 'C:\\it\'s\\path\\' }).trim(), ], [ '${env:SIMPLE}=\'hello\';', @@ -134,6 +136,8 @@ suite('Debug - prepareCommand', () => { '${env:EMPTY}=\'\';', '${env:QUOTE}=\'hello\'\'world\';', '${env:MULTI}=\'it\'\'s \'\'ok\'\'\';', + '${env:TRAILING}=\'C:\\work\\\';', + '${env:BACKSLASH_QUOTE}=\'C:\\it\'\'s\\path\\\';', ]); }); }); From f4bfa76be67d00d07ec4bd986c216b95c9cb71ec Mon Sep 17 00:00:00 2001 From: vritant24 Date: Thu, 20 Aug 2026 07:32:24 -0700 Subject: [PATCH 06/29] Refactor configuration and update test cases for Copilot extension --- extensions/copilot/package.json | 2 +- extensions/copilot/package.nls.json | 2 +- .../common/configurationService.ts | 2 +- .../node/test/copilotChatEndpoint.spec.ts | 17 ++++++++--------- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 164a5c14b487b..4812a01c0ab84 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4449,7 +4449,7 @@ "%github.copilot.config.chatCompletionsTokenParameter.maxCompletionTokens%", "%github.copilot.config.chatCompletionsTokenParameter.maxTokens%" ], - "default": "max_completion_tokens", + "default": "max_tokens", "markdownDescription": "%github.copilot.config.chatCompletionsTokenParameter%", "tags": [ "advanced", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 33837ed90614d..96ecd209dcae6 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -277,7 +277,7 @@ "github.copilot.tools.githubTextSearch.userDescription": "Text search a GitHub repository or organization for files containing specific keywords or code patterns.", "github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.", "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", - "github.copilot.config.chatCompletionsTokenParameter": "Controls the output token limit parameter sent to Chat Completions APIs. Use `max_tokens` only for compatibility with endpoints that do not support `max_completion_tokens`.", + "github.copilot.config.chatCompletionsTokenParameter": "Controls the output token limit parameter sent to custom Chat Completions APIs. Use `max_completion_tokens` for endpoints that do not support `max_tokens`.", "github.copilot.config.chatCompletionsTokenParameter.maxCompletionTokens": "Send `max_completion_tokens`.", "github.copilot.config.chatCompletionsTokenParameter.maxTokens": "Send the legacy `max_tokens` parameter for compatibility.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 8d6a9af2c31cc..cfb0d42bfe09e 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -746,7 +746,7 @@ export namespace ConfigKey { /** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */ export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null); - export const ChatCompletionsTokenParameter = defineSetting('chat.chatCompletionsTokenParameter', ConfigType.ExperimentBased, 'max_completion_tokens', vEnum('max_completion_tokens', 'max_tokens')); + export const ChatCompletionsTokenParameter = defineSetting('chat.chatCompletionsTokenParameter', ConfigType.ExperimentBased, 'max_tokens', vEnum('max_completion_tokens', 'max_tokens')); /** * When enabled, periodic keep-alive probes are sent during long-running tool calls diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index 6fae9295a25df..7dc7baf05838f 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -294,7 +294,7 @@ describe('CopilotChatEndpoint - Chat Completions token parameter (#328418)', () it.each([ { modelId: 'mbe_agent_gpt5_4_oai', family: 'gpt-5.4', displayName: 'custom GPT-5 model', customModel: true }, { modelId: 'custom-claude', family: 'claude-sonnet-4', displayName: 'custom non-GPT model', customModel: true } - ])('sends max_completion_tokens by default for $displayName', ({ modelId, family, displayName, customModel }) => { + ])('preserves max_tokens by default for $displayName', ({ modelId, family, displayName, customModel }) => { const endpoint = createEndpoint(modelId, family, displayName, customModel); const body = endpoint.createRequestBody({ ...createTestOptions([{ @@ -310,8 +310,8 @@ describe('CopilotChatEndpoint - Chat Completions token parameter (#328418)', () max_tokens: body.max_tokens, max_completion_tokens: body.max_completion_tokens }).toEqual({ - max_tokens: undefined, - max_completion_tokens: 256000 + max_tokens: 256000, + max_completion_tokens: undefined }); }); @@ -328,8 +328,8 @@ describe('CopilotChatEndpoint - Chat Completions token parameter (#328418)', () }); }); - it('sends max_tokens when configured for compatibility', () => { - mockServices.configurationService.setConfig(ConfigKey.Advanced.ChatCompletionsTokenParameter, 'max_tokens'); + it('sends max_completion_tokens when enabled', () => { + mockServices.configurationService.setConfig(ConfigKey.Advanced.ChatCompletionsTokenParameter, 'max_completion_tokens'); const endpoint = createEndpoint('custom-model', 'custom-family', 'Custom Model'); const body = endpoint.createRequestBody({ ...createTestOptions([{ @@ -345,13 +345,12 @@ describe('CopilotChatEndpoint - Chat Completions token parameter (#328418)', () max_tokens: body.max_tokens, max_completion_tokens: body.max_completion_tokens }).toEqual({ - max_tokens: 4096, - max_completion_tokens: undefined + max_tokens: undefined, + max_completion_tokens: 4096 }); }); - it('replaces an explicitly provided max_completion_tokens with max_tokens when configured for compatibility', () => { - mockServices.configurationService.setConfig(ConfigKey.Advanced.ChatCompletionsTokenParameter, 'max_tokens'); + it('replaces an explicitly provided max_completion_tokens with max_tokens by default', () => { const endpoint = createEndpoint('custom-model', 'custom-family', 'Custom Model'); const body: IEndpointBody = { max_completion_tokens: 4096 From 2479bb31b2796cabdef006a90065f2797dd8188d Mon Sep 17 00:00:00 2001 From: "zainnadeem(RedOpsCell)" Date: Thu, 20 Aug 2026 21:22:00 +0500 Subject: [PATCH 07/29] Handle PowerShell smart quotes in env values --- src/vs/workbench/contrib/debug/node/terminals.ts | 2 +- src/vs/workbench/contrib/debug/test/node/terminals.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/debug/node/terminals.ts b/src/vs/workbench/contrib/debug/node/terminals.ts index 8f7ddfa1602a1..ed0fea2112f0c 100644 --- a/src/vs/workbench/contrib/debug/node/terminals.ts +++ b/src/vs/workbench/contrib/debug/node/terminals.ts @@ -103,7 +103,7 @@ export function prepareCommand(shell: string, args: string[], argsCanBeInterpret if (value === null) { command += `Remove-Item env:${key}; `; } else { - command += `\${env:${key}}='${value.replace(/'/g, '\'\'')}'; `; + command += `\${env:${key}}='${value.replace(/['\u2018\u2019]/g, quote => quote + quote)}'; `; } } } diff --git a/src/vs/workbench/contrib/debug/test/node/terminals.test.ts b/src/vs/workbench/contrib/debug/test/node/terminals.test.ts index 80d7cd616c85f..fe53e62f7ceb3 100644 --- a/src/vs/workbench/contrib/debug/test/node/terminals.test.ts +++ b/src/vs/workbench/contrib/debug/test/node/terminals.test.ts @@ -120,6 +120,9 @@ suite('Debug - prepareCommand', () => { }); test('powershell - quotes environment values', () => { + const leftSingleQuotationMark = '\u2018'; + const rightSingleQuotationMark = '\u2019'; + assert.deepStrictEqual( [ prepareCommand('powershell', [], false, undefined, { SIMPLE: 'hello' }).trim(), @@ -127,6 +130,8 @@ suite('Debug - prepareCommand', () => { prepareCommand('powershell', [], false, undefined, { EMPTY: '' }).trim(), prepareCommand('powershell', [], false, undefined, { QUOTE: 'hello\'world' }).trim(), prepareCommand('powershell', [], false, undefined, { MULTI: 'it\'s \'ok\'' }).trim(), + prepareCommand('powershell', [], false, undefined, { LEFT_QUOTE: `hello${leftSingleQuotationMark}world` }).trim(), + prepareCommand('powershell', [], false, undefined, { RIGHT_QUOTE: `hello${rightSingleQuotationMark}world` }).trim(), prepareCommand('powershell', [], false, undefined, { TRAILING: 'C:\\work\\' }).trim(), prepareCommand('powershell', [], false, undefined, { BACKSLASH_QUOTE: 'C:\\it\'s\\path\\' }).trim(), ], @@ -136,6 +141,8 @@ suite('Debug - prepareCommand', () => { '${env:EMPTY}=\'\';', '${env:QUOTE}=\'hello\'\'world\';', '${env:MULTI}=\'it\'\'s \'\'ok\'\'\';', + `\${env:LEFT_QUOTE}='hello${leftSingleQuotationMark}${leftSingleQuotationMark}world';`, + `\${env:RIGHT_QUOTE}='hello${rightSingleQuotationMark}${rightSingleQuotationMark}world';`, '${env:TRAILING}=\'C:\\work\\\';', '${env:BACKSLASH_QUOTE}=\'C:\\it\'\'s\\path\\\';', ]); From 2f898b82417a0620ac0e4f1d0c58c377c213b29c Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:37:18 -0700 Subject: [PATCH 08/29] Ask the agent SDK about setup instead of guessing from the filesystem (#331739) * Ask the agent SDK about setup instead of guessing from the filesystem A developer who pays for Claude directly hit a GitHub Copilot sign-in wall, because session-type availability was inferred by sniffing for config files on disk. That guess was wrong in both directions: it gated users who had a working account, and it advertised agents to users who had none. Replace the inference with what the agent's own SDK reports, and make the SDK download an explicit choice rather than something that happens on startup. - Agents publish an SDK setup status (`notDownloaded` / `downloading` / `ready`) over the root config channel, plus the capabilities they offer for getting an account. The workbench derives "no account" from `ready` + zero models, so there is a single wire source per fact. - Agents declare capabilities only; every user-facing string is localized in the workbench via `vs/nls`. - The download is offered by a banner and performed on request. Consent is recorded per agent, so a later version bump re-downloads silently for that agent while a different agent still asks. - Background fetches stay invisible: only the explicit gesture registers download progress interest. - `AgentSdkSetupChannel` holds the nonce handling, in-flight latch and publish ordering once, so Claude and Codex differ only in their capability literals. Removes the filesystem-sniffing paths this replaces: `codexLocalAuth` and the "we discovered your existing configuration" notification, which asked users to sign in again after they had already declined the sign-in modal. Co-Authored-By: Claude Opus 5 * Fix two setup-status races found in review Publish the SDK download status *after* the model catalog on both agents. Publishing `ready` at the top of `_refreshModels` meant the first refresh after a download announced "the SDK is here" while `_models` was still empty -- and `ready` plus zero models is exactly how the window renders "no account found". The invariant was already documented in the setup channel's own `_download()`; the refresh path contradicted it. Re-bind `AgentSdkSetupService` to root state on `onAgentHostStart`. `rootState` is a getter over a protocol client the host replaces on every restart and reconnect, so the single constructor-time subscription went quietly stale -- and because the service is `Delayed`, constructing before the connection bound the no-op state forever. Pending download requests are cleared on re-bind too: a request the previous host never answered never will be, so the Download button comes back rather than staying suppressed. Both fixes carry regression tests that were verified to fail without them. Also corrects the `explicitlyRequested` telemetry doc, which claimed to carry a click-vs-standing-consent split it does not have, and states the banner's ambient-host scope in `agentSdkSetupSessionType`. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../agentHost/common/agentSdkSetup.ts | 162 ++++ .../node/agentSdkDownloadTelemetry.ts | 121 +++ .../agentHost/node/agentSdkDownloader.ts | 39 +- .../agentHost/node/agentSdkSetupChannel.ts | 121 +++ .../agentHost/node/claude/claudeAgent.ts | 160 ++- .../node/claude/claudeAgentSdkService.ts | 9 +- .../node/claude/claudeTransportMode.ts | 113 +-- .../agentHost/node/codex/codexAgent.ts | 143 ++- .../agentHost/node/codex/codexLocalAuth.ts | 70 -- .../test/common/agentSdkSetup.test.ts | 126 +++ .../node/agentSdkDownloadTelemetry.test.ts | 36 + .../test/node/agentSdkDownloader.test.ts | 73 +- .../test/node/claudeAgent.integrationTest.ts | 2 +- .../agentHost/test/node/claudeAgent.test.ts | 907 ++++++++++++------ .../test/node/claudeSubagentResolver.test.ts | 2 +- .../test/node/claudeTransportMode.test.ts | 130 +-- .../test/node/codex/codexAgent.test.ts | 81 +- .../test/node/codex/codexLocalAuth.test.ts | 60 -- .../test/node/codex/codexModelRefresh.test.ts | 588 ++++++++---- .../node/codex/codexSessionConfigKeys.test.ts | 3 +- .../node/codex/codexSessionTitleSpans.test.ts | 3 +- .../test/node/testAgentSdkDownloader.ts | 66 ++ src/vs/sessions/browser/sessionsAuthGate.ts | 35 - .../browser/mobile/mobileSessionTypePicker.ts | 4 +- .../contrib/chat/browser/sessionTypePicker.ts | 4 + .../test/browser/sessionTypePicker.test.ts | 2 + .../agentHostDiscoveredConfigNotification.ts | 162 ---- .../browser/localAgentHost.contribution.ts | 4 +- ...ntHostDiscoveredConfigNotification.test.ts | 140 --- .../test/browser/sessionsAuthGate.test.ts | 46 +- .../agentHost/agentHost.contribution.ts | 2 + .../agentHostSdkSetupNotification.ts | 342 +++++++ .../agentSessions/sessionTypeAvailability.ts | 30 +- .../delegationSessionPickerActionItem.ts | 4 +- .../input/sessionTargetPickerActionItem.ts | 6 + .../agentHostSdkSetupNotification.test.ts | 244 +++++ .../sessionTypeAvailability.test.ts | 33 +- .../sessionTargetPickerActionItem.test.ts | 86 +- .../agentHost/browser/agentSdkSetupService.ts | 258 +++++ .../agentHost/browser/codexAccountService.ts | 9 + .../test/browser/codexAccountService.test.ts | 2 + 41 files changed, 3047 insertions(+), 1381 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentSdkSetup.ts create mode 100644 src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts create mode 100644 src/vs/platform/agentHost/node/agentSdkSetupChannel.ts delete mode 100644 src/vs/platform/agentHost/node/codex/codexLocalAuth.ts create mode 100644 src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentSdkDownloadTelemetry.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/codex/codexLocalAuth.test.ts create mode 100644 src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts delete mode 100644 src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiscoveredConfigNotification.ts delete mode 100644 src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostDiscoveredConfigNotification.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts create mode 100644 src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts diff --git a/src/vs/platform/agentHost/common/agentSdkSetup.ts b/src/vs/platform/agentHost/common/agentSdkSetup.ts new file mode 100644 index 0000000000000..953baebb2508e --- /dev/null +++ b/src/vs/platform/agentHost/common/agentSdkSetup.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { RootState } from './state/protocol/state.js'; + +/** + * Private side-channel describing whether each agent's SDK is on disk yet, and + * what the user can do about it. Rides `publishRootTransientValues` rather than + * AHP proper, alongside `vscode.codexAccount`: the protocol files here are + * generated and version-pinned, so promoting this is a cross-repo change. + * + * One key per agent rather than one key holding a map — transient values are a + * shallow patch, so a shared key would let agents erase each other's entry. + */ +const AGENT_SDK_SETUP_STATUS_KEY_PREFIX = 'vscode.agentSdkSetup.status.'; + +export const AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY = 'vscode.agentSdkSetup.downloadRequest'; + +export function agentSdkSetupStatusKey(agent: string): string { + return `${AGENT_SDK_SETUP_STATUS_KEY_PREFIX}${agent}`; +} + +/** + * Whether the agent's SDK can be loaded without a network fetch. + * + * Deliberately the *only* thing on the wire: account state is derivable from the + * model list, which already flows over AHP — `ready` plus zero models means "no + * account" — and publishing it too would be two sources for one truth. + */ +export type AgentSdkDownloadStatus = 'notDownloaded' | 'downloading' | 'ready'; + +/** + * What an agent declares about its own setup. Capabilities, never UI: no + * user-facing strings ride this channel, because localization belongs in the + * workbench. + */ +export interface IAgentSdkSetupInfo { + /** Agent/provider id, e.g. `'claude'`. */ + readonly agent: string; + readonly download: AgentSdkDownloadStatus; + /** + * Where the user goes to finish setup, for the agents whose setup happens + * outside the app (`claude login`, an exported API key). + */ + readonly setupDocsUrl?: string; + /** + * Display name of the provider this agent can sign in to in-app, e.g. + * `'ChatGPT'`; absent means it has no such flow. A proper noun the workbench + * cannot invent, so it crosses the wire like `displayName` does and is + * interpolated into a localized template rather than shown raw. + */ + readonly signInProviderName?: string; +} + +/** A request the workbench addresses to one agent, made unique so a repeat press is not swallowed. */ +export interface IAgentSdkSetupRequest { + readonly agent: string; + readonly request: string; +} + +export function isAgentSdkSetupRequestFor(value: unknown, agent: string): value is IAgentSdkSetupRequest { + if (!value || typeof value !== 'object') { + return false; + } + const request: Partial = value; + return request.agent === agent && typeof request.request === 'string' && request.request.length > 0; +} + +function readOne(value: unknown, agent: string): IAgentSdkSetupInfo | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const info: Partial = value; + if (info.download !== 'notDownloaded' && info.download !== 'downloading' && info.download !== 'ready') { + return undefined; + } + return { + agent, + download: info.download, + setupDocsUrl: typeof info.setupDocsUrl === 'string' ? info.setupDocsUrl : undefined, + signInProviderName: typeof info.signInProviderName === 'string' && info.signInProviderName.length > 0 ? info.signInProviderName : undefined, + }; +} + +/** + * Every agent that has published a setup status, in root-state key order. Agents + * that have not published are absent rather than guessed at — there is no honest + * default for "we were never told". + */ +export function readAgentSdkSetupInfos(state: RootState | undefined): readonly IAgentSdkSetupInfo[] { + // The one sanctioned hop into the namespaced setup slots; every field read out + // of them is validated in `readOne`. + const meta = state?._meta; + const values = state?.config?.values; + const infos: IAgentSdkSetupInfo[] = []; + const seen = new Set(); + for (const bag of [values, meta]) { + for (const key of Object.keys(bag ?? {})) { + if (!key.startsWith(AGENT_SDK_SETUP_STATUS_KEY_PREFIX)) { + continue; + } + const agent = key.slice(AGENT_SDK_SETUP_STATUS_KEY_PREFIX.length); + if (!agent || seen.has(agent)) { + continue; + } + const info = readOne(bag?.[key], agent); + if (info) { + seen.add(agent); + infos.push(info); + } + } + } + return infos; +} + +/** + * The agents whose SDK the user has agreed to fetch, decoded from storage. A + * malformed or absent record reads as "nobody consented", which costs at worst + * one extra press of a button the user was about to press anyway. + */ +export function readConsentedSdkAgents(stored: string | undefined): ReadonlySet { + if (!stored) { + return new Set(); + } + try { + const parsed: unknown = JSON.parse(stored); + return new Set(Array.isArray(parsed) ? parsed.filter(agent => typeof agent === 'string') : []); + } catch { + return new Set(); + } +} + +export function writeConsentedSdkAgents(agents: ReadonlySet): string { + return JSON.stringify([...agents]); +} + +/** + * Which agents should be asked to fetch their SDK without being offered a + * button, given standing consent. The SDK version is pinned per build + * and the cache keyed by version, so every update invalidates it — daily on + * Insiders. Consent is to "this product downloads the Claude SDK", not to one + * tarball, so re-asking would nag people who already said yes. + * + * It does not carry to a *different* agent: the button says "we need to + * download the Codex Agent SDK", and pressing it is not permission to fetch + * Claude's. + * + * `alreadyRequested` stops a failing download retrying forever: a failed fetch + * republishes `notDownloaded`, and every status change re-runs this. A window is + * the retry unit. + */ +export function resolveConsentedSdkDownloads( + consentedAgents: ReadonlySet, + setups: readonly IAgentSdkSetupInfo[], + alreadyRequested: ReadonlySet, +): readonly string[] { + return setups + .filter(setup => setup.download === 'notDownloaded' && consentedAgents.has(setup.agent) && !alreadyRequested.has(setup.agent)) + .map(setup => setup.agent); +} diff --git a/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts new file mode 100644 index 0000000000000..8993588eefe71 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ILogService } from '../../log/common/log.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import type { IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; + +// #region Failure classification + +/** + * Coarse bucket for a failed SDK fetch. A closed set: the downloader's own error + * strings carry the CDN URL and the cache path, so the raw message can never be + * the reported reason. `notConfigured` and `unsupportedTarget` describe a build + * that cannot fetch this SDK at all, so a non-zero count is a signal in itself. + */ +export type AgentSdkDownloadFailureReason = + | 'cancelled' + | 'network' + | 'filesystem' + | 'extract' + | 'notConfigured' + | 'unsupportedTarget' + | 'unknown'; + +/** + * Order matters. Network before extraction, because an HTTP failure message + * embeds the tarball URL and would otherwise match an archive-shaped hint; + * filesystem errnos before network, because they are unambiguous where a bare + * `EACCES` from a proxy is not. + */ +const FAILURE_HINTS: readonly (readonly [AgentSdkDownloadFailureReason, readonly string[]])[] = [ + ['notConfigured', ['no `product.agentSdks', 'unknown placeholder']], + ['unsupportedTarget', ['no SDK target for this host']], + ['filesystem', ['ENOSPC', 'EACCES', 'EPERM', 'EROFS', 'EBUSY', 'EMFILE', 'ENAMETOOLONG', 'EXDEV']], + ['network', ['HTTP ', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPROTO', 'ECONNABORTED', 'socket hang up', 'certificate', 'tunneling socket', 'getaddrinfo']], + ['extract', ['TAR_', 'zlib', 'gzip', 'unexpected end of file', 'incorrect header check', 'invalid entry']], +]; + +/** + * Bucket a downloader failure message. Substring matching, because the messages + * are assembled from Node errnos, `node-tar` diagnostics and our own wrappers — + * none of which carry a stable code by the time they arrive here. Anything + * unrecognised is `unknown` rather than guessed at: a rising `unknown` share is + * the signal to add a hint, which a neighbouring bucket would hide. + */ +export function classifyAgentSdkDownloadFailure(error: string | undefined): AgentSdkDownloadFailureReason { + if (!error) { + return 'unknown'; + } + // The downloader reports cancellation as this exact token, not as a message. + if (error === 'cancelled') { + return 'cancelled'; + } + const haystack = error.toLowerCase(); + for (const [reason, hints] of FAILURE_HINTS) { + if (hints.some(hint => haystack.includes(hint.toLowerCase()))) { + return reason; + } + } + return 'unknown'; +} + +// #endregion + +// #region Telemetry + +interface IAgentSdkDownloadEvent { + packageId: string; + phase: string; + failureReason: string; + explicitlyRequested: boolean; + durationMs: number; + receivedBytes: number; + totalBytes: number; +} + +type AgentSdkDownloadClassification = { + 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.' }; + explicitlyRequested: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setup flow drove this download and showed its progress — a click, or a quiet re-fetch under standing consent — as opposed to a background fetch nobody was watching.' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the download had been running when it reached this phase.' }; + receivedBytes: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Bytes fetched by the time this phase was reached.' }; + totalBytes: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total size the server advertised, or zero when it did not.' }; + owner: 'TylerLeonhardt'; + comment: 'The middle of the agent SDK setup funnel: whether an offered download is actually attempted, and whether it works.'; +}; + +/** + * Report one endpoint of a download. Callers pass only terminal and `started` + * frames; the throttled `progress` frames are not counted. + * `explicitlyRequested` splits setup-driven downloads from background ones, not + * clicks from standing consent — both hold a progress interest. That split is + * the funnel's own `downloadClicked` / `consentedDownload` steps. + */ +export function reportAgentSdkDownload( + telemetryService: ITelemetryService, + logService: ILogService, + progress: IAgentSdkDownloadProgress, + durationMs: number, +): void { + const failureReason = progress.phase === 'failed' ? classifyAgentSdkDownloadFailure(progress.error) : ''; + telemetryService.publicLog2('agentHost.agentSdkDownload', { + packageId: progress.packageId, + phase: progress.phase, + failureReason, + explicitlyRequested: progress.explicitlyRequested, + durationMs, + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes ?? 0, + }); + logService.info( + `[AgentSdkDownloader] ${progress.packageId}: ${progress.phase}` + + ` (explicit=${progress.explicitlyRequested}, bytes=${progress.receivedBytes}/${progress.totalBytes ?? 'unknown'}, ms=${durationMs}` + + `${failureReason ? `, reason=${failureReason}` : ''})`, + ); +} + +// #endregion diff --git a/src/vs/platform/agentHost/node/agentSdkDownloader.ts b/src/vs/platform/agentHost/node/agentSdkDownloader.ts index 0e4786cb61acd..1d59ac95cb129 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloader.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloader.ts @@ -21,7 +21,9 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { IRequestService } from '../../request/common/request.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IRequestContext } from '../../../base/parts/request/common/request.js'; +import { reportAgentSdkDownload } from './agentSdkDownloadTelemetry.js'; // #region Per-package strategy @@ -273,6 +275,7 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade @IRequestService private readonly _requestService: IRequestService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); } @@ -320,9 +323,11 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade return override; } - // 2. Negative cache: a recent failure short-circuits without I/O. + // 2. Negative cache: a recent failure short-circuits without I/O. Not for a + // user who asked by hand, though — the latch exists to stop background retry + // storms, not to leave a Download button doing nothing for half a minute. const latched = this._failureLatch.get(pkg.id); - if (latched && latched.expiresAt > Date.now()) { + if (latched && latched.expiresAt > Date.now() && !this._explicitProgressInterest.has(pkg.id)) { throw latched.error; } @@ -380,8 +385,14 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade // that crashed mid-way never write it. See `_download` for why // the sentinel is written inside the tmp dir before the rename. if (await this._fileService.exists(sentinel)) { + // Logged, not counted: a cache hit happens on every SDK method call + // and would drown the download funnel. It matters here because "was + // the SDK already there?" is the first question asked of a log where + // no download was ever attempted. + this._logService.trace(`[AgentSdkDownloader] ${pkg.id}: cache hit at ${cacheDir}`); return cacheDir; } + this._logService.info(`[AgentSdkDownloader] ${pkg.id}: cache miss for version ${config.version} (${sdkTarget}); a download is required`); // Download (deduped across concurrent callers in the same process). // cacheDir is already unique per (pkg, version, sdkTarget) — within @@ -439,14 +450,14 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade const downloadId = generateUuid(); let lastReceived = 0; let lastTotal: number | undefined; - this._fireProgress(pkg, downloadId, 'started', 0, undefined); + this._fireProgress(pkg, downloadId, start, 'started', 0, undefined); try { const tarballPath = path.join(tmpDir, 'sdk.tgz'); await this._fetch(url, tarballPath, token, (receivedBytes, totalBytes) => { lastReceived = receivedBytes; lastTotal = totalBytes; - this._fireProgress(pkg, downloadId, 'progress', receivedBytes, totalBytes); + this._fireProgress(pkg, downloadId, start, 'progress', receivedBytes, totalBytes); }); await this._extractTarGz(tarballPath, tmpDir); await this._fileService.del(URI.file(tarballPath)); @@ -469,24 +480,24 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade } catch (err) { if (await this._handleRenameLoser(err, sentinel, tmpDirUri)) { this._logService.info(`[AgentSdkDownloader] ${pkg.id}: lost rename race, using existing cache`); - this._fireProgress(pkg, downloadId, 'completed', lastReceived, lastTotal); + this._fireProgress(pkg, downloadId, start, 'completed', lastReceived, lastTotal); return cacheDir; } throw err; } const elapsed = Math.round((Date.now() - start) / 1000); - this._logService.info(`[AgentSdkDownloader] ${pkg.id}: downloaded in ${elapsed}s`); - this._fireProgress(pkg, downloadId, 'completed', lastTotal ?? lastReceived, lastTotal); + this._logService.info(`[AgentSdkDownloader] ${pkg.id}: downloaded ${lastTotal ?? lastReceived} bytes in ${elapsed}s`); + this._fireProgress(pkg, downloadId, start, 'completed', lastTotal ?? lastReceived, lastTotal); return cacheDir; } catch (err) { await this._delIgnoringMissing(tmpDirUri); if (token.isCancellationRequested) { - this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, 'cancelled'); + this._fireProgress(pkg, downloadId, start, 'failed', lastReceived, lastTotal, 'cancelled'); throw new CancellationError(); } const message = err instanceof Error ? err.message : String(err); - this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, message); + this._fireProgress(pkg, downloadId, start, 'failed', lastReceived, lastTotal, message); throw new Error( `Failed to download ${pkg.id} SDK from ${url} ` + `(cache target: ${cacheDir}). ` + @@ -499,12 +510,13 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade private _fireProgress( pkg: IAgentSdkPackage, downloadId: string, + startedAt: number, phase: AgentSdkDownloadPhase, receivedBytes: number, totalBytes: number | undefined, error?: string, ): void { - this._onDidDownloadProgress.fire({ + const progress: IAgentSdkDownloadProgress = { downloadId, packageId: pkg.id, displayName: pkg.displayName, @@ -513,7 +525,12 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade totalBytes, explicitlyRequested: this._explicitProgressInterest.has(pkg.id), ...(error !== undefined ? { error } : {}), - }); + }; + this._onDidDownloadProgress.fire(progress); + // Endpoints only — the throttled `progress` frames would flood the funnel. + if (phase !== 'progress') { + reportAgentSdkDownload(this._telemetryService, this._logService, progress, Date.now() - startedAt); + } } private async _handleRenameLoser( diff --git a/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts b/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts new file mode 100644 index 0000000000000..98efa3127933a --- /dev/null +++ b/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AgentSdkDownloadStatus, IAgentSdkSetupInfo, agentSdkSetupStatusKey, isAgentSdkSetupRequestFor } from '../common/agentSdkSetup.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentSdkDownloader, IAgentSdkPackage } from './agentSdkDownloader.js'; + +/** The per-agent half of {@link AgentSdkSetupChannel}. */ +export interface IAgentSdkSetupChannelAgent { + /** Agent/provider id, which becomes {@link IAgentSdkSetupInfo.agent}. */ + readonly id: string; + readonly sdkPackage: IAgentSdkPackage; + + /** What this agent offers besides the download. Published verbatim. */ + readonly setupInfo: Omit; + + /** Whether the SDK can be loaded without a network fetch. */ + isSdkLocal(): Promise; + + /** Fetch the SDK. Only ever called for the explicit gesture. */ + downloadSdk(): Promise; + + /** Restart chat discovery, which defers itself while there is no SDK to read a catalog from. */ + restartChatDiscovery(): void; + + /** Re-enumerate models against the SDK that just landed. */ + refreshModels(): Promise; +} + +/** + * One agent's side of the SDK setup channel: publishes whether its SDK is on + * disk, and performs the download the workbench asks for. Every agent needs the + * same nonce handling, latching and publish ordering, so only the calls in + * {@link IAgentSdkSetupChannelAgent} differ. + */ +export class AgentSdkSetupChannel extends Disposable { + + /** Consumed request nonce, so a root-config change we caused isn't re-handled. */ + private _lastRequest: string | undefined; + + /** + * Latched while the *explicit* download runs. {@link IAgentSdkSetupChannelAgent.isSdkLocal} + * stays false throughout, so without this the channel could only ever report + * `notDownloaded` and the banner would keep offering a button for work already + * underway. Deliberately not a query on the downloader, which would also latch + * for background fetches — those are the ones the user never asked for and so + * must stay invisible. + */ + private _downloadInFlight = false; + + constructor( + private readonly _agent: IAgentSdkSetupChannelAgent, + private readonly _configurationService: IAgentConfigurationService, + private readonly _downloader: IAgentSdkDownloader, + private readonly _logService: ILogService, + ) { + super(); + // The workbench addresses the agent through the root config bag. The key is + // cleared as it is consumed so a later identical press still lands. + this._register(this._configurationService.onDidRootConfigChange(() => this._handleRequest())); + queueMicrotask(() => { void this.publish(); }); + } + + /** Publish the current status, paying for the is-local probe. */ + async publish(): Promise { + this.publishWith(await this._agent.isSdkLocal()); + } + + /** The synchronous half, for callers that have just paid for the probe. */ + publishWith(sdkIsLocal: boolean): void { + const download: AgentSdkDownloadStatus = this._downloadInFlight + ? 'downloading' + : sdkIsLocal ? 'ready' : 'notDownloaded'; + const info: Omit = { ...this._agent.setupInfo, download }; + this._configurationService.publishRootTransientValues?.({ [agentSdkSetupStatusKey(this._agent.id)]: info }); + } + + private _handleRequest(): void { + const request = this._configurationService.getRootConfigValues?.()[AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]; + if (!isAgentSdkSetupRequestFor(request, this._agent.id) || request.request === this._lastRequest) { + return; + } + this._lastRequest = request.request; + this._configurationService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: undefined }); + void this._download(); + } + + /** + * The explicit download gesture. Acquiring progress interest is what makes the + * fetch visible: the downloader only emits frames for a session that asked or an + * explicitly-registered interest, and this download belongs to no session. + */ + private async _download(): Promise { + if (this._downloadInFlight) { + return; + } + const progressInterest = this._downloader.acquireDownloadProgressInterest(this._agent.sdkPackage); + this._downloadInFlight = true; + this.publishWith(false); + try { + this._logService.info(`[AgentSdkSetup] ${this._agent.id}: downloading the agent SDK at the user's request`); + await this._agent.downloadSdk(); + } catch (error) { + this._logService.error(error, `[AgentSdkSetup] ${this._agent.id}: agent SDK download failed`); + } finally { + this._downloadInFlight = false; + progressInterest.dispose(); + } + // Chat discovery deferred itself while there was no SDK to read the catalog + // from; this is the one moment that can change. + this._agent.restartChatDiscovery(); + // Second, not first: the refresh is what asks the fresh SDK about the account, + // so announcing `ready` ahead of it would show "no account found" to a user + // who has one for as long as enumeration takes. + await this._agent.refreshModels(); + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 026930e22311c..6c28556a625a7 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -20,6 +20,8 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { IAgentSdkDownloader } from '../agentSdkDownloader.js'; +import { AgentSdkSetupChannel } from '../agentSdkSetupChannel.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; import { buildSideChatSourceContext, prepareSideChatPrompt, sliceSideChatTurns } from '../agentPeerChats.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; @@ -44,9 +46,9 @@ import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointSer import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { projectFromCopilotContext } from '../copilot/copilotGitProject.js'; import { ICopilotApiService } from '../shared/copilotApiService.js'; -import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; +import { ClaudeSdkPackage, IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { buildModelEnumerationOptions } from './claudeSdkOptions.js'; -import { detectExistingClaudeSetup, resolveClaudeTransportMode, type ClaudeTransportMode } from './claudeTransportMode.js'; +import { isClaudeAccountSetUp, resolveClaudeTransportMode, type ClaudeTransportMode } from './claudeTransportMode.js'; import { mergeClaudeModelCatalogs, resolveClaudeSessionTransport } from './claudeModelSelection.js'; import { mapSessionMessagesToTurns, resolveForkAnchorUuid } from './claudeReplayMapper.js'; import { getSubagentTranscript } from './claudeSubagentResolver.js'; @@ -66,6 +68,9 @@ import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js const USER_AGENT_PREFIX = 'vscode_claude_code'; +/** Where a user goes to establish Claude credentials; the workbench labels the button. */ +const CLAUDE_SETUP_DOCS_URL = 'https://docs.claude.com/en/docs/claude-code/setup'; + /** * Returns true if `m` is a Claude-family model that should be advertised * to clients picking a model for the Claude provider. @@ -605,6 +610,7 @@ export class ClaudeAgent extends Disposable implements IAgent { @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @IClaudeProxyService private readonly _claudeProxyService: IClaudeProxyService, @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, + @IAgentSdkDownloader private readonly _agentSdkDownloader: IAgentSdkDownloader, @IAgentHostSessionTitleSignal private readonly _sessionTitleSignal: IAgentHostSessionTitleSignal, @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @@ -645,8 +651,28 @@ export class ClaudeAgent extends Disposable implements IAgent { // (see {@link _defaultTransportMode}), so a sign-in state change needs no // reactive re-resolve — the next session simply reads it live. queueMicrotask(() => { void this._startModelRefresh(); }); + + this._sdkSetupChannel = this._register(new AgentSdkSetupChannel({ + id: this.id, + sdkPackage: ClaudeSdkPackage, + // Every Claude credential — subscription or `ANTHROPIC_API_KEY` — is + // established outside the app, and the SDK exposes no login control + // request, so the docs link is the only route this agent can offer. + setupInfo: { setupDocsUrl: CLAUDE_SETUP_DOCS_URL }, + isSdkLocal: () => this._sdkService.canLoadWithoutDownload(), + downloadSdk: () => this._sdkService.ensureAvailable(), + restartChatDiscovery: () => this._restartChatDiscovery(), + refreshModels: () => this._startModelRefresh(), + }, this._configurationService, this._agentSdkDownloader, this._logService)); } + /** + * Publishes whether the SDK is on disk — and deliberately nothing about the + * account, which the workbench derives from the model list (`ready` + zero + * models → no account). Two wire sources for one truth could disagree. + */ + private readonly _sdkSetupChannel: AgentSdkSetupChannel; + /** * The fallback transport for a session whose model names no provider (model-less * or a bare/legacy id). Read on demand at materialize — never cached — from live @@ -657,19 +683,14 @@ export class ClaudeAgent extends Disposable implements IAgent { */ private _defaultTransportMode(): ClaudeTransportMode { const allowSignedOutWhenUsable = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true; - return resolveClaudeTransportMode({ allowSignedOutWhenUsable, hasGitHubToken: this._proxyHandle !== undefined, hasExistingSetup: this._hasUsableNativeSetup() }); + return resolveClaudeTransportMode({ allowSignedOutWhenUsable, hasGitHubToken: this._proxyHandle !== undefined, hasExistingSetup: this._nativeAccountSetUp }); } /** - * Whether Claude can run without GitHub right now: the signed-out opt-in is on - * AND a BYO-Anthropic credential is discoverable (see - * {@link detectExistingClaudeSetup}). Backs both the advertised requirement and - * the model-less transport default so the two cannot disagree. + * The SDK's last answer to {@link isClaudeAccountSetUp}, kept current by + * {@link _refreshModels}. Starts `false`: unasked is not evidence of an account. */ - private _hasUsableNativeSetup(): boolean { - return this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true - && detectExistingClaudeSetup(this._environmentService.userHome.fsPath); - } + private _nativeAccountSetUp = false; // #region Descriptor + auth @@ -690,14 +711,14 @@ export class ClaudeAgent extends Disposable implements IAgent { } getProtectedResources(): ProtectedResourceMetadata[] { - // Kept in the list even when optional, never dropped: - // `authenticateProtectedResources` matches on `resource` and ignores - // `required`, so advertising it is what lets the host silently forward a - // token to an already-signed-in user — and acquire the proxy handle - // Copilot-routed models need — without forcing sign-in on anyone else. + // Always listed, always optional. Listing it is what lets the host forward a + // token to an already-signed-in user (matching ignores `required`); the + // unconditional `required: false` is what stops `resolveSignedOutWindowGate` + // walling off the whole Agents window before the user reaches a surface that + // could explain itself. const copilotResource = this._gitHubEndpointService.getCopilotResource(); return [ - this._hasUsableNativeSetup() ? { ...copilotResource, required: false } : copilotResource, + { ...copilotResource, required: false }, this._gitHubEndpointService.getRepoResource(), ]; } @@ -858,14 +879,14 @@ export class ClaudeAgent extends Disposable implements IAgent { /** * Enumerate both providers' catalogs in parallel and publish them as one * provider-qualified list via {@link mergeClaudeModelCatalogs}. Each source is - * optional — the proxy catalog needs a GitHub token, the native catalog needs a - * local Claude setup — so a source we can't attempt contributes an empty list - * rather than failing the whole refresh. {@link Promise.allSettled} tolerates - * one source erroring; only when *every* source we attempted fails do we keep - * the last known-good catalog instead of blanking, so a transient double - * failure never wipes the picker. + * optional — the proxy catalog needs a GitHub token, the native catalog needs the + * SDK on disk — so a source we can't attempt contributes an empty list rather + * than failing the whole refresh. {@link Promise.allSettled} tolerates one source + * erroring; only when *every* source we attempted fails do we keep the last + * known-good catalog instead of blanking, so a transient double failure never + * wipes the picker. * - * Gating the native half on {@link detectExistingClaudeSetup} is deliberate and + * Gating the native half on the SDK's own account report is deliberate and * load-bearing, not just an optimization. `supportedModels()` returns a *static* * list of models the SDK understands — it is not an entitlement or credential * check, and it answers even with no `ANTHROPIC_API_KEY`, no @@ -874,14 +895,24 @@ export class ClaudeAgent extends Disposable implements IAgent { * reads downstream as "usable without GitHub" and would hold the Agents window * open on an agent that fails on its first turn. An empty catalog is the honest * signal: it surfaces as "no models" (`SessionTypeAuthRequirement.Unusable`) - * rather than a sign-in prompt that would not help. + * rather than a sign-in prompt that would not help. The empty list is also what + * the window reads account state *from*, so it must never be a guess. + * + * The native attempt is skipped while the SDK is not on disk: asking it anything + * costs a multi-hundred-megabyte download, and that download is the user's + * explicit choice to make. */ private async _refreshModels(): Promise { const tokenAtStart = this._githubToken; - const hasNativeSetup = detectExistingClaudeSetup(this._environmentService.userHome.fsPath); + // True only for a dev override, a dev bare import, or an already-cached SDK. + const canAttemptNative = await this._sdkService.canLoadWithoutDownload(); + if (!canAttemptNative) { + // No SDK, so no evidence of an account — say so rather than retaining a stale `true`. + this._nativeAccountSetUp = false; + } const [proxyOutcome, nativeOutcome] = await Promise.allSettled([ tokenAtStart ? this._fetchProxyModels(tokenAtStart) : Promise.resolve([]), - hasNativeSetup ? this._fetchNativeModels() : Promise.resolve([]), + canAttemptNative ? this._fetchNativeModels() : Promise.resolve([]), ]); // Stale-write guard: a newer refresh superseded this one while we were // awaiting — the proxy token rotated (sign-in / sign-out). A merged write @@ -889,29 +920,33 @@ export class ClaudeAgent extends Disposable implements IAgent { if (this._githubToken !== tokenAtStart) { return; } - const attempted = (tokenAtStart ? 1 : 0) + (hasNativeSetup ? 1 : 0); + const attempted = (tokenAtStart ? 1 : 0) + (canAttemptNative ? 1 : 0); const failed = (proxyOutcome.status === 'rejected' ? 1 : 0) + (nativeOutcome.status === 'rejected' ? 1 : 0); if (attempted > 0 && failed === attempted) { // Every source we attempted failed — keep the last known-good catalog // rather than blanking. Sources we didn't attempt resolve fulfilled-empty // and are not counted as failures. this._logService.error('[Claude] All attempted model sources failed (merged refresh); keeping last known-good catalog'); - return; + } else { + // Unwrap each settled fetch: its models on success, or an empty list on + // rejection (logged) so the other provider's catalog still publishes. + const settledCatalog = (outcome: PromiseSettledResult, label: string): readonly IAgentModelInfo[] => { + if (outcome.status === 'fulfilled') { + return outcome.value; + } + this._logService.error(outcome.reason, `[Claude] Failed to fetch ${label} models (merged refresh); keeping the other provider`); + return []; + }; + const proxyModels = settledCatalog(proxyOutcome, 'proxy'); + const nativeModels = settledCatalog(nativeOutcome, 'native'); + const merged = mergeClaudeModelCatalogs(proxyModels, nativeModels); + this._logService.info(`[Claude] Models refreshed (merged). Count: ${merged.length}, ${merged.map(m => m.name).join(', ')}`); + this._models.set(merged, undefined); } - // Unwrap each settled fetch: its models on success, or an empty list on - // rejection (logged) so the other provider's catalog still publishes. - const settledCatalog = (outcome: PromiseSettledResult, label: string): readonly IAgentModelInfo[] => { - if (outcome.status === 'fulfilled') { - return outcome.value; - } - this._logService.error(outcome.reason, `[Claude] Failed to fetch ${label} models (merged refresh); keeping the other provider`); - return []; - }; - const proxyModels = settledCatalog(proxyOutcome, 'proxy'); - const nativeModels = settledCatalog(nativeOutcome, 'native'); - const merged = mergeClaudeModelCatalogs(proxyModels, nativeModels); - this._logService.info(`[Claude] Models refreshed (merged). Count: ${merged.length}, ${merged.map(m => m.name).join(', ')}`); - this._models.set(merged, undefined); + // Last, never first: this is a free republish of "is the SDK on disk" (some + // other path may have fetched it), but announcing `ready` before the catalog + // lands is exactly how the window renders "no account found". + this._sdkSetupChannel.publishWith(canAttemptNative); } /** @@ -922,6 +957,11 @@ export class ClaudeAgent extends Disposable implements IAgent { * yields, so no turn runs and no session transcript is written (verified * Phase 19 E2E). Projected with no commercial metadata, minus the SDK's * {@link isSdkDefaultModel} alias row. + * + * `accountInfo()` rides the *same* query, so asking is effectively free — and it + * is the only honest source for "does this user have a Claude setup": a + * `claude login` credential lives in the login keychain, where nothing on the + * filesystem can see it. When it says no, the catalog is published empty. */ private async _fetchNativeModels(): Promise { // A prompt iterable that never yields: enumeration only needs the @@ -932,7 +972,14 @@ export class ClaudeAgent extends Disposable implements IAgent { const options = buildModelEnumerationOptions(); const query = await this._sdkService.query({ prompt: neverYieldingPrompt, options }); try { - const models = await query.supportedModels(); + const [account, models] = await Promise.all([query.accountInfo(), query.supportedModels()]); + const setUp = isClaudeAccountSetUp(account); + this._nativeAccountSetUp = setUp; + // Origin only — never the credential itself. + this._logService.info(`[Claude] Native account check: setUp=${setUp}, provider=${account.apiProvider ?? 'none'}, tokenSource=${account.tokenSource ?? 'absent'}, apiKeySource=${account.apiKeySource ?? 'absent'}`); + if (!setUp) { + return []; + } return models .filter(m => !isSdkDefaultModel(m)) .map(m => fromSdkModelInfo(m, this.id)); @@ -2047,10 +2094,11 @@ export class ClaudeAgent extends Disposable implements IAgent { } async listChatsToMigrate(): Promise { - try { - await this._sdkService.ensureAvailableForDiscovery(); - } catch (err) { - this._logService.warn('[Claude] SDK unavailable while listing chats to migrate', err); + // `undefined` is "can't enumerate yet", which is the honest answer while the + // SDK is absent: the catalog lives inside it, but fetching one is the user's + // call. {@link _restartChatDiscovery} revisits this once they make it. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring the migratable chat list'); return undefined; } const chats = await this._listClaudeCodeChats(); @@ -2067,7 +2115,13 @@ export class ClaudeAgent extends Disposable implements IAgent { private _startClaudeCodeChatDiscovery(): Promise { if (!this._claudeCodeChatDiscovery) { this._claudeCodeChatDiscovery = retry(async () => { - await this._sdkService.ensureAvailableForDiscovery(); + // Waits for the SDK rather than pulling it down — see + // {@link listChatsToMigrate}. Returning leaves the retry loop happy, + // since no amount of retrying will make the user press Download. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring chat discovery'); + return; + } if (!(await this._emitClaudeCodeChats())) { throw new Error('Claude chat catalog is not available'); } @@ -2077,6 +2131,14 @@ export class ClaudeAgent extends Disposable implements IAgent { return this._claudeCodeChatDiscovery; } + /** Runs discovery again for whoever is still subscribed, after it deferred for want of an SDK. */ + private _restartChatDiscovery(): void { + if (this._claudeCodeChatDiscovery) { + this._claudeCodeChatDiscovery = undefined; + void this._startClaudeCodeChatDiscovery(); + } + } + private async _emitClaudeCodeChats(): Promise { try { const chats = await this._listClaudeCodeChats(); diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts index 46a2df473581e..d53bc976c3879 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts @@ -75,10 +75,11 @@ export interface IClaudeAgentSdkService { */ canLoadWithoutDownload(): Promise; /** - * Ensures the SDK is available for native chat discovery without loading - * the module. + * Downloads the SDK if it isn't local yet, without loading the module. This + * is the explicit gesture: background callers gate on + * {@link canLoadWithoutDownload} instead and do without. */ - ensureAvailableForDiscovery(): Promise; + ensureAvailable(): Promise; forkSession(sessionId: string, options?: ForkSessionOptions): Promise; deleteSession(sessionId: string, options?: SessionMutationOptions): Promise; @@ -178,7 +179,7 @@ export class ClaudeAgentSdkService implements IClaudeAgentSdkService { return this._downloader.isSdkResolvableWithoutDownload(ClaudeSdkPackage); } - async ensureAvailableForDiscovery(): Promise { + async ensureAvailable(): Promise { if (!(await this.canLoadWithoutDownload())) { await this._downloader.loadSdkRoot(ClaudeSdkPackage, CancellationToken.None); } diff --git a/src/vs/platform/agentHost/node/claude/claudeTransportMode.ts b/src/vs/platform/agentHost/node/claude/claudeTransportMode.ts index 0e165ddac3f3d..e594e31820121 100644 --- a/src/vs/platform/agentHost/node/claude/claudeTransportMode.ts +++ b/src/vs/platform/agentHost/node/claude/claudeTransportMode.ts @@ -3,12 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { readFileSync } from 'fs'; -import { parse as parseJSONC, type ParseError } from '../../../../base/common/json.js'; -import { join } from '../../../../base/common/path.js'; -import { isFalsyOrWhitespace } from '../../../../base/common/strings.js'; -import { isString } from '../../../../base/common/types.js'; -import { vObj, vOptionalProp, vUnknown, type ValidatorType } from '../../../../base/common/validation.js'; +import type { AccountInfo } from '@anthropic-ai/claude-agent-sdk'; /** * Resolved Claude host transport. `proxy` routes Anthropic traffic through the @@ -25,7 +20,7 @@ export interface IClaudeTransportModeInputs { readonly allowSignedOutWhenUsable: boolean; /** Whether a GitHub Copilot token has been captured (i.e. signed in). */ readonly hasGitHubToken: boolean; - /** Whether an existing local Claude setup was detected (see {@link detectExistingClaudeSetup}). */ + /** Whether the SDK reported a Claude setup usable on the user's own credentials (see {@link isClaudeAccountSetUp}). */ readonly hasExistingSetup: boolean; } @@ -47,16 +42,14 @@ export interface IClaudeTransportModeInputs { * forced to sign in. * * The result is **not** an input to the Agents window's sign-in gate, and - * resolving to `proxy` does not by itself make the session type "require - * GitHub". That answer is `getProtectedResources()`, which marks the Copilot - * resource `required: false` on the same `hasExistingSetup` fact used here — so - * the two agree by construction: a user with their own Anthropic credential is - * not forced to sign in, and one without (case 4) is. `resolveAgentAuthRequirement` - * then separates `None` from `Unusable` on the *model count*, since a - * `required: false` agent that cannot enumerate a single model must not hold the - * window open. The proxy fallback of case 4 only bites at use time, when a - * model-less/bare session actually materializes with no proxy handle and - * `_ensureAuthenticated` raises `AHP_AUTH_REQUIRED`. + * resolving to `proxy` does not by itself make the session type "require GitHub". + * `getProtectedResources()` marks the Copilot resource `required: false` + * unconditionally, so nothing decided here can raise a sign-in wall. What + * separates `None` from `Unusable` downstream is the *model count*, published + * from the same `accountInfo()` answer that feeds `hasExistingSetup` here — so + * the two cannot disagree about one user. The proxy fallback of case 4 only + * bites at use time, when a model-less session materializes with no proxy handle + * and `_ensureAuthenticated` raises `AHP_AUTH_REQUIRED`. * * There is deliberately no host-global setting to *prefer* a transport. Since * the picker offers both providers' models side by side, transport is downstream @@ -81,70 +74,30 @@ export function resolveClaudeTransportMode(inputs: IClaudeTransportModeInputs): } /** - * Validators for the `~/.claude/settings.json` sources that indicate a usable - * native setup, kept separate — and holding `unknown` rather than `vString()` — - * so one malformed entry reads as absent instead of voiding its siblings. - * {@link hasValue} is what decides usability. - */ -const claudeApiKeyHelperValidator = vObj({ - apiKeyHelper: vOptionalProp(vUnknown()), -}); - -const claudeSettingsEnvValidator = vObj({ - env: vOptionalProp(vObj({ - ANTHROPIC_API_KEY: vOptionalProp(vUnknown()), - ANTHROPIC_AUTH_TOKEN: vOptionalProp(vUnknown()), - ANTHROPIC_BASE_URL: vOptionalProp(vUnknown()), - CLAUDE_CODE_OAUTH_TOKEN: vOptionalProp(vUnknown()), - })), -}); - -/** - * The `env` shape both `process.env` and `~/.claude/settings.json` are probed - * for, derived from {@link claudeSettingsEnvValidator} so the two never drift. - */ -type ClaudeNativeEnv = NonNullable['env']>; - -/** - * Whether a local Claude setup exists that can run without GitHub: a recognized - * credential or endpoint key in `env` or `/.claude/settings.json`, or - * that file's `apiKeyHelper`. Each source is read independently, so a malformed - * value never masks a usable one. + * Whether the SDK's own account report describes a Claude setup that can serve + * requests on the user's own credentials — the single rule behind both the + * advertised requirement and the native model catalog. Only the SDK can answer + * honestly: a `claude login` credential lives in the macOS keychain, invisible + * to `process.env` and `~/.claude/settings.json` alike. + * + * The two branches must NOT be collapsed. `apiProvider` reports `'firstParty'` + * even for an empty home directory, so it is a presence signal for nobody — it + * is consulted only to spot a *third-party* backend (Bedrock, Vertex, a + * gateway), whose credential fields the SDK documents as absent because auth is + * external. Requiring a credential field there would lock every one of them out. + * + * Says *configured*, not *working*: verifying would cost a billable request per + * check, and the failure being fixed here is genuinely set-up users locked out. */ -export function detectExistingClaudeSetup(homeDir: string, env: NodeJS.ProcessEnv = process.env): boolean { - if (hasNativeClaudeEnv(env)) { - return true; +export function isClaudeAccountSetUp(account: AccountInfo | undefined): boolean { + if (!account) { + return false; } - const settings = readJsonFile(join(homeDir, '.claude', 'settings.json')); - return hasNativeClaudeEnv(claudeSettingsEnvValidator.validate(settings).content?.env) - || hasValue(claudeApiKeyHelperValidator.validate(settings).content?.apiKeyHelper); -} - -/** True when any recognized native-Claude key carries a usable value. */ -function hasNativeClaudeEnv(env: ClaudeNativeEnv | undefined): boolean { - return hasValue(env?.ANTHROPIC_API_KEY) - || hasValue(env?.ANTHROPIC_AUTH_TOKEN) - || hasValue(env?.ANTHROPIC_BASE_URL) - || hasValue(env?.CLAUDE_CODE_OAUTH_TOKEN); -} - -/** A setting counts only when it actually carries a value, never a blank leftover. */ -function hasValue(value: unknown): value is string { - return isString(value) && !isFalsyOrWhitespace(value); -} - -/** Parsed JSON, or `undefined` when the file is missing, unreadable or malformed. */ -function readJsonFile(path: string): unknown { - let text: string; - try { - text = readFileSync(path, 'utf8'); - } catch { - return undefined; + if (account.apiProvider !== undefined && account.apiProvider !== 'firstParty') { + return true; } - // The tolerant parser reports on `errors` rather than throwing, and salvages a - // partial result from broken input — so a truncated file has to be rejected - // here, or half a credential reads as a setup the CLI could not load either. - const errors: ParseError[] = []; - const parsed: unknown = parseJSONC(text, errors, { allowTrailingComma: true, allowEmptyContent: true }); - return errors.length === 0 ? parsed : undefined; + // `tokenSource` spells "no credential" as `'none'` rather than absence; + // `apiKeySource` has only ever been observed absent in that case. + return (account.tokenSource !== undefined && account.tokenSource !== 'none') + || account.apiKeySource !== undefined; } diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 79f419b054ffe..80266c48a93aa 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -25,6 +25,7 @@ import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID, createAgentModelSourceMeta } from '../../common/agentModelSource.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import { AgentSdkSetupChannel } from '../agentSdkSetupChannel.js'; import { CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, type ICodexAccountInfo } from '../../common/codexAccount.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import { AgentSession, AgentSignal, CODEX_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatResult, IAgentCreateChatOptions, IAgentDescriptor, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSpawnChatEvent, IMcpNotification, resolveAgentChatContext, resolveAgentHostInstructions, type AgentProvider, type AuthenticateParams } from '../../common/agent.js'; @@ -138,7 +139,6 @@ import type { ConfigReadResponse } from './protocol/generated/v2/ConfigReadRespo import type { ConfigWriteResponse } from './protocol/generated/v2/ConfigWriteResponse.js'; import { formatGuardianDenialNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from './codexGuardianReview.js'; import { CODEX_COMPACT_SLASH_COMMAND } from '../codexCompactCommand.js'; -import { detectExistingCodexChatGPTSetup } from './codexLocalAuth.js'; const CLIENT_INFO = { name: 'vscode_agent_host', @@ -175,6 +175,16 @@ const CODEX_THINKING_LEVEL_KEY = 'thinkingLevel'; */ const USER_AGENT_PREFIX = 'vscode_codex'; +/** Where a user finishes setting Codex up outside the app; the workbench labels the button. */ +const CODEX_SETUP_DOCS_URL = 'https://learn.chatgpt.com/codex/auth'; + +/** + * The account the in-app sign-in signs into. A proper noun rather than a + * translatable string, so publishing it keeps user-facing text out of the host — + * the workbench interpolates it into its own localized sentence. + */ +const CODEX_SIGN_IN_PROVIDER_NAME = 'ChatGPT'; + const CODEX_REASONING_EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high']; /** @@ -1146,13 +1156,36 @@ export class CodexAgent extends Disposable implements IAgent { this._configurationService.updateRootConfig({ [CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY]: undefined }); void this._signOutOfChatGPT(); } - this._startModelRefreshForExistingChatGPTSetup(); + this._startModelRefreshWhenSdkIsLocal(); this._queueProviderConfigurationWrite(); })); void this._refreshProviderConfiguration(); - this._startModelRefreshForExistingChatGPTSetup(); + this._startModelRefreshWhenSdkIsLocal(); + this._sdkSetupChannel = this._register(new AgentSdkSetupChannel({ + id: this.id, + sdkPackage: CodexSdkPackage, + setupInfo: { + setupDocsUrl: CODEX_SETUP_DOCS_URL, + // ChatGPT sign-in is a control request the app server answers, so the + // banner can start it in-window. An API key still has to be established + // outside — hence the docs link alongside it. + signInProviderName: CODEX_SIGN_IN_PROVIDER_NAME, + }, + isSdkLocal: () => this._isSdkResolvableWithoutDownload(), + downloadSdk: async () => { await this._resolveSdkRoot(); }, + restartChatDiscovery: () => this._restartChatDiscovery(), + refreshModels: () => this.refreshModels(), + }, this._configurationService, this._agentSdkDownloader, this._logService)); } + /** + * Publishes whether the SDK is on disk — and nothing about the account, which + * the workbench derives from the model list already flowing over AHP (`ready` + * + zero models → no account). Distinct from {@link _publishAccountInfo}'s + * `vscode.codexAccount` channel, which drives the ChatGPT account menu. + */ + private readonly _sdkSetupChannel: AgentSdkSetupChannel; + private _setOpenAIAccountState(state: ICodexAccountState, _publish = true): void { this._openAIAccountState = state; if (state.status !== 'signedIn' || state.authType !== 'chatgpt') { @@ -1234,13 +1267,14 @@ export class CodexAgent extends Disposable implements IAgent { // #region Auth getProtectedResources(): ProtectedResourceMetadata[] { - // Keep the Copilot resource advertised even when optional so an existing - // token is still forwarded and Copilot-backed models remain additive. - // Without a usable ChatGPT setup, however, Copilot is the only available - // transport and must stay required so the workbench shows its auth gate. + // Always listed, always optional — matching Claude. Listing it is what lets + // the host forward a token to an already-signed-in user (matching ignores + // `required`); the unconditional `required: false` is what stops + // `resolveSignedOutWindowGate` walling off the whole Agents window before + // the user reaches a surface that could explain itself. const copilotResource = this._gitHubEndpointService.getCopilotResource(); return [ - this._hasExistingChatGPTSetup() ? { ...copilotResource, required: false } : copilotResource, + { ...copilotResource, required: false }, this._gitHubEndpointService.getRepoResource(), ]; } @@ -1702,42 +1736,35 @@ export class CodexAgent extends Disposable implements IAgent { } private async _refreshModels(): Promise { - await Promise.all([this._refreshCopilotModels(), this._refreshCodexModels()]); + const [, sdkReady] = await Promise.all([this._refreshCopilotModels(), this._refreshCodexModels()]); this._models.set([...this._copilotModels, ...this._codexModels], undefined); - } - - private _hasExistingChatGPTSetup(): boolean { - const allowSignedOutWhenUsable = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true; - if (!allowSignedOutWhenUsable) { - return false; - } - if (this._openAIAccountState.status === 'signedIn') { - return this._openAIAccountState.authType === 'chatgpt'; - } - if (this._openAIAccountState.status === 'unavailable') { - return this._openAIAccountState.requiresOpenaiAuth === false; - } - if (this._openAIAccountState.status === 'signedOut' || this._openAIAccountState.status === 'error') { - return false; - } - return detectExistingCodexChatGPTSetup( - this._environmentService.userHome.fsPath, - process.env, - process.env[AgentHostCodexAgentCodexHomeEnvVar], - ); + // Last, never first: also the freshest answer to "is the SDK here" (a + // download that landed elsewhere surfaces here), but announcing `ready` + // before the catalog lands is how the window renders "no account found". + this._sdkSetupChannel.publishWith(sdkReady); } /** - * Match Claude native mode: once persisted credentials make the provider - * usable without GitHub, eagerly materialize the SDK and publish only the - * authoritative app-server model catalog. Until that finishes the provider - * remains present but unusable; no cached or synthetic model is advertised. + * Ask the app server for the authoritative catalog at startup, but only when + * asking is free — i.e. the SDK is already on disk. + * + * Replaces a `~/.codex/auth.json` sniff that was wrong in both directions: it + * missed API-key setups established through the environment, and claimed a + * setup from a stale token file. Only the app server can answer whether this + * user can run Codex without GitHub. Behind the flag, so a Copilot-only user + * still spawns nothing at startup. */ - private _startModelRefreshForExistingChatGPTSetup(): void { - if (!this._hasExistingChatGPTSetup() || this._codexModels.length > 0) { + private _startModelRefreshWhenSdkIsLocal(): void { + const allowSignedOutWhenUsable = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true; + if (!allowSignedOutWhenUsable || this._codexModels.length > 0) { return; } - queueMicrotask(() => { void this.refreshModels(); }); + queueMicrotask(async () => { + if (this._store.isDisposed || !(await this._isSdkResolvableWithoutDownload())) { + return; + } + await this.refreshModels(); + }); } private async _refreshCopilotModels(): Promise { @@ -1792,17 +1819,25 @@ export class CodexAgent extends Disposable implements IAgent { } } - private async _refreshCodexModels(): Promise { + private async _refreshCodexModels(): Promise { + // Outside the `try` so a throw still reports what we had established about + // the SDK, rather than a `false` the caller would publish as "not downloaded". + let sdkReady = false; try { - if (this._connection.kind === 'idle' && !(await this._isSdkResolvableWithoutDownload()) && !this._hasExistingChatGPTSetup()) { + // A refresh must never be what pulls the SDK down — the download is an + // explicit gesture now — so with no local SDK this reports the honest + // empty catalog and the banner offers it. A live connection already + // proves the SDK is on disk, so it short-circuits the stat. + sdkReady = this._connection.kind !== 'idle' || await this._isSdkResolvableWithoutDownload(); + if (!sdkReady) { this._codexModels = []; - return; + return sdkReady; } const connection = await this._ensureConnection(); const account = await this._refreshAccount(connection.client, false); if (account.status === 'signedOut' || account.status === 'error') { this._codexModels = []; - return; + return sdkReady; } const configResponse = await connection.client.request<'config/read', ConfigReadResponse>('config/read', { includeLayers: false }); const modelProvider = configResponse.config.model_provider ?? CODEX_OPENAI_MODEL_PROVIDER; @@ -1831,6 +1866,7 @@ export class CodexAgent extends Disposable implements IAgent { // Keep the last known-good catalog; a transient periodic failure must // not make every model disappear. } + return sdkReady; } // #endregion @@ -5607,10 +5643,11 @@ export class CodexAgent extends Disposable implements IAgent { } async listChatsToMigrate(): Promise { - try { - await this._resolveSdkRoot(); - } catch (err) { - this._logService.warn(`[Codex] SDK unavailable while listing chats to migrate: ${err instanceof Error ? err.message : String(err)}`); + // `undefined` is "can't enumerate yet", which is the honest answer while the + // SDK is absent: the catalog lives inside it, but fetching one is the user's + // call. {@link _restartChatDiscovery} revisits this once they make it. + if (!(await this._isSdkResolvableWithoutDownload())) { + this._logService.info('[Codex] SDK not downloaded yet; deferring the migratable chat list'); return undefined; } const chats = await this._listCodexChats(); @@ -5627,7 +5664,13 @@ export class CodexAgent extends Disposable implements IAgent { private _startCodexChatDiscovery(): Promise { if (!this._codexChatDiscovery) { this._codexChatDiscovery = retry(async () => { - await this._resolveSdkRoot(); + // Waits for the SDK rather than pulling it down — see + // {@link listChatsToMigrate}. Returning leaves the retry loop happy, + // since no amount of retrying will make the user press Download. + if (!(await this._isSdkResolvableWithoutDownload())) { + this._logService.info('[Codex] SDK not downloaded yet; deferring chat discovery'); + return; + } if (!(await this._emitCodexChats())) { throw new Error('Codex chat catalog is not available'); } @@ -5637,6 +5680,14 @@ export class CodexAgent extends Disposable implements IAgent { return this._codexChatDiscovery; } + /** Runs discovery again for whoever is still subscribed, after it deferred for want of an SDK. */ + private _restartChatDiscovery(): void { + if (this._codexChatDiscovery) { + this._codexChatDiscovery = undefined; + void this._startCodexChatDiscovery(); + } + } + private async _emitCodexChats(): Promise { try { const chats = await this._listCodexChats(); diff --git a/src/vs/platform/agentHost/node/codex/codexLocalAuth.ts b/src/vs/platform/agentHost/node/codex/codexLocalAuth.ts deleted file mode 100644 index b04d0633b4772..0000000000000 --- a/src/vs/platform/agentHost/node/codex/codexLocalAuth.ts +++ /dev/null @@ -1,70 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { readFileSync } from 'fs'; -import { join } from '../../../../base/common/path.js'; - -/** Resolve the same config directory as Codex without requiring its binary. */ -function resolveCodexHome(userHome: string, env: NodeJS.ProcessEnv, codexHome: string | undefined): string { - return codexHome || env.CODEX_HOME || join(userHome, '.codex'); -} - -function readJson(path: string): unknown { - try { - return JSON.parse(readFileSync(path, 'utf8')); - } catch { - return undefined; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; -} - -function hasChatGPTTokens(value: unknown): boolean { - return isRecord(value) && (isNonEmptyString(value.access_token) || isNonEmptyString(value.refresh_token)); -} - -/** - * Detect an existing persisted ChatGPT identity without starting or downloading - * Codex. This mirrors Codex's `AuthDotJson::resolved_mode` classification for - * the modes that `account/read` exposes as a ChatGPT account. API keys, - * Bedrock, headers, and Agent Identity deliberately do not count. - * - * Token expiry is not checked here: managed ChatGPT auth commonly has an - * expired access token alongside a refresh token, and app-server remains the - * authority that refreshes and validates it before the first request. - */ -export function detectExistingCodexChatGPTSetup(userHome: string, env: NodeJS.ProcessEnv = process.env, codexHome?: string): boolean { - const auth = readJson(join(resolveCodexHome(userHome, env, codexHome), 'auth.json')); - if (!isRecord(auth)) { - return false; - } - - const authMode = auth.auth_mode; - if (authMode === 'personalAccessToken') { - return isNonEmptyString(auth.personal_access_token); - } - if (authMode === 'chatgpt' || authMode === 'chatgptAuthTokens') { - return hasChatGPTTokens(auth.tokens); - } - if (authMode !== undefined) { - return false; - } - - // Legacy Codex auth files predate `auth_mode`: PAT wins first, then - // `OPENAI_API_KEY`, and otherwise token material means managed ChatGPT auth. - if (isNonEmptyString(auth.personal_access_token)) { - return true; - } - if (isNonEmptyString(auth.OPENAI_API_KEY) || auth.bedrock_api_key !== undefined || auth.agent_identity !== undefined) { - return false; - } - return hasChatGPTTokens(auth.tokens); -} diff --git a/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts b/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts new file mode 100644 index 0000000000000..581fd741f65d1 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { agentSdkSetupStatusKey, isAgentSdkSetupRequestFor, readAgentSdkSetupInfos, readConsentedSdkAgents, resolveConsentedSdkDownloads, writeConsentedSdkAgents, type IAgentSdkSetupInfo } from '../../common/agentSdkSetup.js'; + +suite('Agent SDK setup channel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads one entry per agent, from the transient meta bag', () => { + assert.deepStrictEqual(readAgentSdkSetupInfos({ + agents: [], + _meta: { + [agentSdkSetupStatusKey('claude')]: { download: 'ready', setupDocsUrl: 'https://example.test/claude' }, + [agentSdkSetupStatusKey('codex')]: { download: 'notDownloaded', signInProviderName: 'ChatGPT' }, + }, + }), [ + { agent: 'claude', download: 'ready', setupDocsUrl: 'https://example.test/claude', signInProviderName: undefined }, + { agent: 'codex', download: 'notDownloaded', setupDocsUrl: undefined, signInProviderName: 'ChatGPT' }, + ]); + }); + + test('a persisted config value wins over the transient meta bag for the same agent', () => { + assert.deepStrictEqual(readAgentSdkSetupInfos({ + agents: [], + config: { schema: { type: 'object', properties: {} }, values: { [agentSdkSetupStatusKey('claude')]: { download: 'ready' } } }, + _meta: { [agentSdkSetupStatusKey('claude')]: { download: 'notDownloaded' } }, + }), [ + { agent: 'claude', download: 'ready', setupDocsUrl: undefined, signInProviderName: undefined }, + ]); + }); + + test('an agent that never published is absent rather than guessed at', () => { + assert.deepStrictEqual(readAgentSdkSetupInfos({ agents: [] }), []); + assert.deepStrictEqual(readAgentSdkSetupInfos(undefined), []); + }); + + test('drops entries whose download status is not one we understand', () => { + assert.deepStrictEqual(readAgentSdkSetupInfos({ + agents: [], + _meta: { + [agentSdkSetupStatusKey('claude')]: { download: 'somethingElse' }, + [agentSdkSetupStatusKey('codex')]: 'not an object', + [agentSdkSetupStatusKey('')]: { download: 'ready' }, + 'vscode.codexAccount': { status: 'signedIn' }, + }, + }), []); + }); + + test('drops optional fields that are wrong-typed, or right-typed but useless', () => { + assert.deepStrictEqual(readAgentSdkSetupInfos({ + agents: [], + _meta: { + // An empty provider name would render a "Sign in to " button, so it is + // dropped like a wrong type rather than passed through. + [agentSdkSetupStatusKey('claude')]: { download: 'ready', setupDocsUrl: 42, signInProviderName: '' }, + }, + }), [ + { agent: 'claude', download: 'ready', setupDocsUrl: undefined, signInProviderName: undefined }, + ]); + }); + + test('a request is only for the agent it names, and only when it carries a nonce', () => { + assert.strictEqual(isAgentSdkSetupRequestFor({ agent: 'claude', request: 'abc' }, 'claude'), true); + assert.strictEqual(isAgentSdkSetupRequestFor({ agent: 'claude', request: 'abc' }, 'codex'), false); + assert.strictEqual(isAgentSdkSetupRequestFor({ agent: 'claude', request: '' }, 'claude'), false); + assert.strictEqual(isAgentSdkSetupRequestFor({ agent: 'claude' }, 'claude'), false); + assert.strictEqual(isAgentSdkSetupRequestFor(undefined, 'claude'), false); + // The key is cleared by writing `undefined`, which is what a consumed + // request looks like on the next change event. + assert.strictEqual(isAgentSdkSetupRequestFor('claude', 'claude'), false); + }); + + suite('standing consent', () => { + const claude: IAgentSdkSetupInfo = { agent: 'claude', download: 'notDownloaded' }; + const codex: IAgentSdkSetupInfo = { agent: 'codex', download: 'notDownloaded' }; + const none: ReadonlySet = new Set(); + const both: ReadonlySet = new Set(['claude', 'codex']); + + test('a consented user whose cache a version bump invalidated re-downloads with no gate', () => { + assert.deepStrictEqual(resolveConsentedSdkDownloads(both, [claude, codex], none), ['claude', 'codex']); + }); + + test('a user who never consented still sees the offer', () => { + assert.deepStrictEqual(resolveConsentedSdkDownloads(new Set(), [claude, codex], none), []); + }); + + test('consenting to one agent is not consent to fetch another', () => { + // The button that records this says "download the Codex Agent SDK". + assert.deepStrictEqual(resolveConsentedSdkDownloads(new Set(['codex']), [claude, codex], none), ['codex']); + }); + + test('an SDK already on disk, or already fetching, is left alone', () => { + assert.deepStrictEqual(resolveConsentedSdkDownloads(both, [ + { ...claude, download: 'ready' }, + { ...codex, download: 'downloading' }, + ], none), []); + }); + + test('a download that failed is not retried until the next window', () => { + // The failure republishes `notDownloaded`, and every status change re-runs + // this — without the guard that is an unbounded retry loop. + assert.deepStrictEqual(resolveConsentedSdkDownloads(both, [claude, codex], new Set(['claude'])), ['codex']); + }); + + test('the consent record survives a round trip, and a corrupt one consents to nobody', () => { + assert.deepStrictEqual({ + roundTrip: [...readConsentedSdkAgents(writeConsentedSdkAgents(both))], + absent: [...readConsentedSdkAgents(undefined)], + corrupt: [...readConsentedSdkAgents('{not json')], + wrongShape: [...readConsentedSdkAgents('{"claude":true}')], + // A stray non-string entry drops out rather than poisoning the set. + mixed: [...readConsentedSdkAgents('["claude",7]')], + }, { + roundTrip: ['claude', 'codex'], + absent: [], + corrupt: [], + wrongShape: [], + mixed: ['claude'], + }); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentSdkDownloadTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentSdkDownloadTelemetry.test.ts new file mode 100644 index 0000000000000..22d7529cfd6ea --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentSdkDownloadTelemetry.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { classifyAgentSdkDownloadFailure, type AgentSdkDownloadFailureReason } from '../../node/agentSdkDownloadTelemetry.js'; + +// Reporting itself is exercised end-to-end against the real downloader in +// `agentSdkDownloader.test.ts`; only the classifier is worth a table here. +suite('Agent SDK download telemetry', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('failure classification', () => { + const cases: readonly { readonly name: string; readonly error: string | undefined; readonly expected: AgentSdkDownloadFailureReason }[] = [ + { name: 'the downloader reports cancellation as a bare token, not a message', error: 'cancelled', expected: 'cancelled' }, + { name: 'an HTTP status is the network', error: 'HTTP 503 for https://cdn.example.test/claude-1.2.3.tgz', expected: 'network' }, + { name: 'so is a DNS or TLS failure', error: 'getaddrinfo ENOTFOUND cdn.example.test', expected: 'network' }, + { name: 'a full or read-only disk is not', error: `ENOSPC: no space left on device, write '/home/u/.cache/sdk.tgz'`, expected: 'filesystem' }, + { name: 'nor is a permission denied under the cache dir', error: `EACCES: permission denied, mkdir '/home/u/.cache'`, expected: 'filesystem' }, + { name: 'a corrupt archive is its own bucket', error: 'zlib: incorrect header check', expected: 'extract' }, + { name: 'a build with no SDK configured says so', error: 'no `product.agentSdks.claude` in this build', expected: 'notConfigured' }, + { name: 'and one with no artefact for this platform says that', error: 'no SDK target for this host (linux-riscv64)', expected: 'unsupportedTarget' }, + { name: 'an HTTP failure is not read as a corrupt tarball just because the URL ends in .tgz', error: 'HTTP 404 for https://cdn.example.test/sdk.tar.gz', expected: 'network' }, + { name: 'anything unrecognised stays unknown rather than being folded into a neighbour', error: 'something went wrong', expected: 'unknown' }, + { name: 'a failure with no message at all is unknown too', error: undefined, expected: 'unknown' }, + ]; + + for (const { name, error, expected } of cases) { + test(name, () => { + assert.strictEqual(classifyAgentSdkDownloadFailure(error), expected); + }); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts index 9df7962594660..b97ae40414277 100644 --- a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts @@ -18,6 +18,8 @@ import { FileService } from '../../../files/common/fileService.js'; import type { IFileService } from '../../../files/common/files.js'; import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService, NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; +import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { RequestService } from '../../../request/node/requestService.js'; import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage, type IAgentSdkDownloadProgress } from '../../node/agentSdkDownloader.js'; import { ClaudeSdkPackage } from '../../node/claude/claudeAgentSdkService.js'; @@ -25,6 +27,14 @@ import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; import type { INativeEnvironmentService } from '../../../environment/common/environment.js'; import type { IProductService } from '../../../product/common/productService.js'; +class RecordingTelemetryService extends NullTelemetryServiceShape { + readonly events: { name: string; data: Record }[] = []; + + override publicLog2(eventName?: string, data?: Record): void { + this.events.push({ name: eventName ?? '', data: data ?? {} }); + } +} + interface ITestSdkDownloadFixture { tarballPath: string; innerFile: string; // path that should exist inside the extracted root @@ -234,7 +244,7 @@ suite('AgentSdkDownloader', () => { * explicitly. Pass `productConfig: null` to omit the agentSdks block * entirely (the "no product config" case). */ - function makeDownloader(productConfig?: { version?: string; urlTemplate?: string } | null) { + function makeDownloader(productConfig?: { version?: string; urlTemplate?: string } | null, telemetryService: ITelemetryService = NullTelemetryService) { const config = productConfig === null ? undefined : { version: productConfig?.version ?? '1.0.0', urlTemplate: productConfig?.urlTemplate ?? `http://127.0.0.1:${server.port}/sdk-{sdkTarget}.tgz`, @@ -245,6 +255,7 @@ suite('AgentSdkDownloader', () => { makeRequestService(disposables), makeFileService(disposables), new NullLogService(), + telemetryService, )); } @@ -306,8 +317,42 @@ suite('AgentSdkDownloader', () => { assert.strictEqual(completed.receivedBytes, tarballSize); }); + test('loadSdkRoot: counts only the endpoints of a download, with the time it took', async () => { + const telemetry = new RecordingTelemetryService(); + const downloader = makeDownloader(undefined, telemetry); + + await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); + + // The throttled `progress` frames drive the progress bar, not the funnel. + assert.deepStrictEqual(telemetry.events.map(event => [event.name, event.data.phase]), [ + ['agentHost.agentSdkDownload', 'started'], + ['agentHost.agentSdkDownload', 'completed'], + ]); + const completed = telemetry.events[1].data; + assert.strictEqual(completed.packageId, 'claude'); + assert.strictEqual(completed.failureReason, ''); + assert.strictEqual(completed.explicitlyRequested, false); + assert.ok(typeof completed.durationMs === 'number' && completed.durationMs >= 0); + }); + + test('loadSdkRoot: a failed download reports its bucket, never the raw cause', async () => { + const telemetry = new RecordingTelemetryService(); + // Port 1 on loopback refuses instantly, so this is a network failure with + // no bytes and no advertised total. + const downloader = makeDownloader({ urlTemplate: 'http://127.0.0.1:1/sdk-{sdkTarget}.tgz' }, telemetry); + + await assert.rejects(() => downloader.loadSdkRoot(ClaudeSdkPackage, newToken())); + + const failure = telemetry.events[telemetry.events.length - 1].data; + assert.strictEqual(failure.phase, 'failed'); + assert.strictEqual(failure.failureReason, 'network'); + assert.strictEqual(failure.totalBytes, 0, 'an unknown total is reported as zero rather than dropped'); + assert.ok(!JSON.stringify(failure).includes(userDataPath), 'the on-disk cache path must not reach telemetry'); + }); + test('loadSdkRoot: marks progress explicitly requested by a user-initiated flow', async () => { - const downloader = makeDownloader(); + const telemetry = new RecordingTelemetryService(); + const downloader = makeDownloader(undefined, telemetry); const samples: IAgentSdkDownloadProgress[] = []; disposables.add(downloader.onDidDownloadProgress(p => samples.push(p))); disposables.add(downloader.acquireDownloadProgressInterest(ClaudeSdkPackage)); @@ -316,6 +361,30 @@ suite('AgentSdkDownloader', () => { assert.ok(samples.length >= 2); assert.ok(samples.every(sample => sample.explicitlyRequested)); + // The same split reaches telemetry, which is what separates a button press + // from a quiet re-fetch under standing consent. + assert.ok(telemetry.events.every(event => event.data.explicitlyRequested === true)); + }); + + test('loadSdkRoot: a user asking by hand retries through the negative cache', async () => { + // Port 1 refuses instantly, so every attempt here fails. The latch rethrows + // the *same* error it stored, while a real attempt builds a new one — which + // is how this tells a short-circuit from a retry without a second server. + const downloader = makeDownloader({ urlTemplate: 'http://127.0.0.1:1/sdk-{sdkTarget}.tgz' }); + const failure = async () => { + try { + await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); + throw new Error('expected the download to fail'); + } catch (err) { + return err; + } + }; + + const first = await failure(); + assert.strictEqual(await failure(), first, 'a background caller is short-circuited by the latch'); + + disposables.add(downloader.acquireDownloadProgressInterest(ClaudeSdkPackage)); + assert.notStrictEqual(await failure(), first, 'an explicit request is a fresh mandate to try again'); }); test('loadSdkRoot: cache hit returns immediately without re-downloading', async () => { diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 69166cf147cb4..1ba7a034e411f 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -405,7 +405,7 @@ class ProxyRoundTripSdkService implements IClaudeAgentSdkService { return true; } - async ensureAvailableForDiscovery(): Promise { } + async ensureAvailable(): Promise { } async getSessionInfo(_sessionId: string): Promise { return undefined; diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 298c1648327a4..aa093bd0ce65a 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type Anthropic from '@anthropic-ai/sdk'; -import type { AgentInfo, ForkSessionOptions, ForkSessionResult, GetSessionMessagesOptions, McpSdkServerConfigWithInstance, McpServerStatus, ModelInfo, Options, PermissionMode, Query, SDKControlInterruptResponse, SDKMessage, SDKSessionInfo, SDKUserMessage, SdkMcpToolDefinition, SessionMessage, SessionMutationOptions, Settings, SlashCommand, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { AccountInfo, AgentInfo, ForkSessionOptions, ForkSessionResult, GetSessionMessagesOptions, McpSdkServerConfigWithInstance, McpServerStatus, ModelInfo, Options, PermissionMode, Query, SDKControlInterruptResponse, SDKMessage, SDKSessionInfo, SDKUserMessage, SdkMcpToolDefinition, SessionMessage, SessionMutationOptions, Settings, SlashCommand, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { CCAModel } from '@vscode/copilot-api'; @@ -32,7 +32,6 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { join } from '../../../../base/common/path.js'; import { generateUuid, isUUID } from '../../../../base/common/uuid.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -78,7 +77,9 @@ import { createClaudeInternalMcpServerCustomization } from '../../node/claude/cu import { ClaudeSessionMetadataStore } from '../../node/claude/claudeSessionMetadataStore.js'; import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'; import { ClaudeAgentSdkService, IClaudeAgentSdkService, IClaudeSdkBindings } from '../../node/claude/claudeAgentSdkService.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../common/agentSdkSetup.js'; import { IAgentSdkDownloader } from '../../node/agentSdkDownloader.js'; +import { RecordingAgentSdkDownloader } from './testAgentSdkDownloader.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { IClaudeProxyCreditsReport, IClaudeProxyHandle, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; import { resolvePromptToContentBlocks } from '../../node/claude/claudePromptResolver.js'; @@ -508,6 +509,14 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { supportedModelsCallCount = 0; readonly supportedModelsOptions: Options[] = []; + /** + * Programmable `accountInfo()` report. Defaults to the shape measured on a + * machine with nothing configured, so a test that does not opt in gets the + * honest "no account" answer; {@link NATIVE_ACCOUNT} is the opt-in. + */ + accountInfoResult: AccountInfo = { tokenSource: 'none', apiProvider: 'firstParty' }; + accountInfoCallCount = 0; + /** * Optional gate awaited by {@link FakeQuery.supportedModels} before it * resolves. Lets a test park the native half of a merged refresh mid-flight @@ -517,6 +526,13 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { */ supportedModelsGate: Promise | undefined; + /** + * Programmable rejection for the native half of a merged refresh. Distinct + * from a *fulfilled* empty enumeration, which is an honest "no native models"; + * a rejection is "we could not find out". + */ + supportedModelsRejection: Error | undefined; + /** All warm queries produced by {@link startup}. Last entry is the most recent. */ readonly warmQueries: FakeWarmQuery[] = []; @@ -543,11 +559,24 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { return this.canLoadWithoutDownloadResult; } - ensureAvailableForDiscoveryCalls = 0; - async ensureAvailableForDiscovery(): Promise { - this.ensureAvailableForDiscoveryCalls++; + ensureAvailableCalls = 0; + async ensureAvailable(): Promise { + this.ensureAvailableCalls++; + if (this.ensureAvailableRejection) { + throw this.ensureAvailableRejection; + } + // Deliberately does NOT flip {@link canLoadWithoutDownloadResult}: a real + // fetch takes seconds, so tests stage that flip themselves when they + // release the gate. + await this.ensureAvailableGate; } + /** Optional gate awaited by {@link ensureAvailable}, so a test can park a download mid-flight. */ + ensureAvailableGate: Promise | undefined; + + /** Programmable failure for an explicit download (dead CDN, disk full). */ + ensureAvailableRejection: Error | undefined; + /** * Programmable result for {@link canLoadWithoutDownload}. Defaults to * `true` (SDK already local). Set to `false` to simulate the cold-start @@ -874,6 +903,9 @@ class FakeQuery implements AsyncGenerator { } supportedModels(): Promise { this._sdk.supportedModelsCallCount++; + if (this._sdk.supportedModelsRejection) { + return Promise.reject(this._sdk.supportedModelsRejection); + } const gate = this._sdk.supportedModelsGate; return gate ? gate.then(() => this._sdk.supportedModelsResult) : Promise.resolve(this._sdk.supportedModelsResult); } @@ -903,7 +935,10 @@ class FakeQuery implements AsyncGenerator { error_count: 0, }) as never; } - accountInfo(): never { throw new Error('FakeQuery: accountInfo not modeled'); } + accountInfo(): Promise { + this._sdk.accountInfoCallCount++; + return Promise.resolve(this._sdk.accountInfoResult); + } rewindFiles(): never { throw new Error('FakeQuery: rewindFiles not modeled'); } readFile(): never { throw new Error('FakeQuery: readFile not modeled'); } seedReadState(): never { throw new Error('FakeQuery: seedReadState not modeled'); } @@ -1059,6 +1094,7 @@ interface ITestContext { readonly otelService: RecordingOTelService; readonly instantiationService: IInstantiationService; readonly fileService: IFileService; + readonly sdkDownloader: RecordingAgentSdkDownloader; } /** @@ -1083,12 +1119,17 @@ class CapturingLogService extends NullLogService { function createTestContext( disposables: Pick, - overrides?: { logService?: ILogService; database?: TestSessionDatabase; sessionDataService?: ISessionDataService; rootConfig?: Record; userHome?: URI; gitHubEndpointService?: IAgentHostGitHubEndpointService; checkpointService?: IAgentHostCheckpointService }, + overrides?: { logService?: ILogService; database?: TestSessionDatabase; sessionDataService?: ISessionDataService; rootConfig?: Record; userHome?: URI; gitHubEndpointService?: IAgentHostGitHubEndpointService; checkpointService?: IAgentHostCheckpointService; nativeAccount?: AccountInfo }, ): ITestContext { const proxy = new FakeClaudeProxyService(); const api = new FakeCopilotApiService(); api.models = async () => [...ALL_MODELS]; const sdk = new FakeClaudeAgentSdkService(); + // Staged before the agent is constructed: its ctor queues the first model + // refresh, which is what asks for the account. + if (overrides?.nativeAccount) { + sdk.accountInfoResult = overrides.nativeAccount; + } const sessionData = new RecordingSessionDataService( overrides?.sessionDataService ?? (overrides?.database @@ -1106,6 +1147,7 @@ function createTestContext( disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); const otelService = new RecordingOTelService(); + const sdkDownloader = new RecordingAgentSdkDownloader(); const services = new ServiceCollection( [IFileService, fileService], [INativeEnvironmentService, { userHome: overrides?.userHome ?? URI.file('/mock-home') } as INativeEnvironmentService], @@ -1114,6 +1156,7 @@ function createTestContext( [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, sdkDownloader], [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, overrides?.checkpointService ?? NULL_CHECKPOINT_SERVICE], @@ -1167,7 +1210,7 @@ function createTestContext( chats.changeAgent = (chat, nextAgent, context) => changeAgent(chat, nextAgent, toChatContext(chat, context)); const getMessages = chats.getMessages.bind(agent.chats); chats.getMessages = (chat, context) => getMessages(chat, toChatContext(chat, context)); - return { agent, proxy, api, sdk, sessionData, stateManager, configService, otelService, instantiationService, fileService }; + return { agent, proxy, api, sdk, sessionData, stateManager, configService, otelService, instantiationService, fileService, sdkDownloader }; } /** Drains the microtask queue so awaited refresh writes settle. */ @@ -1176,21 +1219,12 @@ function tick(): Promise { } /** - * Run `body` against a temp `$HOME/.claude/settings.json` carrying an Anthropic - * key so {@link detectExistingClaudeSetup} reports a usable native setup, then - * always clean the directory up. Pair with `allowSignedOutWhenUsable` to make a - * signed-out agent resolve its model-less default to native. + * The SDK account report of a user signed in on their own credentials — the + * `claude login` / keychain case no filesystem check could ever see. Pass as + * `nativeAccount` to make an agent publish native models. */ -async function withNativeSetup(body: (userHome: URI) => Promise): Promise { - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/claude-native-setup-`)); - await fs.mkdir(join(userHome.fsPath, '.claude'), { recursive: true }); - await fs.writeFile(join(userHome.fsPath, '.claude', 'settings.json'), JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-ant-test-key' } }), 'utf8'); - try { - await body(userHome); - } finally { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - } -} +const NATIVE_ACCOUNT: AccountInfo = { tokenSource: 'ANTHROPIC_AUTH_TOKEN', apiProvider: 'firstParty' }; + /** * A two-turn source transcript (`u1`/`a1`, `u2`/`a2`) used by the Phase 6.5 @@ -1206,22 +1240,6 @@ function forkSourceMessages(sourceId: string): SessionMessage[] { ]; } -/** - * Stub for {@link IAgentSdkDownloader} consumed by tests that need a real - * `ClaudeAgentSdkService` constructor but override `_loadSdk` themselves — - * the downloader is therefore never actually called. - */ -function stubAgentSdkDownloader(): IAgentSdkDownloader { - return { - _serviceBrand: undefined, - onDidDownloadProgress: Event.None, - acquireDownloadProgressInterest: () => toDisposable(() => { }), - isAvailable: () => false, - isSdkResolvableWithoutDownload: async () => false, - loadSdkRoot: () => { throw new Error('test stub: downloader.loadSdkRoot should not be called'); }, - }; -} - /** * Foundational services every {@link ClaudeAgentSession} requires for its * customization disk scan: an in-memory {@link IFileService} (nothing is @@ -1331,7 +1349,9 @@ suite('ClaudeAgent', () => { resource_name: 'GitHub Copilot', authorization_servers: ['https://github.com/login/oauth'], scopes_supported: ['read:user', 'user:email'], - required: true, + // Shape check; the `required` flag itself is the subject of + // 'the Copilot resource is unconditionally optional […]'. + required: false, }, { resource: 'https://api.github.com/repos', resource_name: 'GitHub Repository', @@ -1390,51 +1410,48 @@ suite('ClaudeAgent', () => { }); test('signed-in probe flips inferred-native to proxy (allowSignedOutWhenUsable)', async () => { - // The fix for the startup catch-22: with the exp flag on and a local Claude - // setup present, a signed-OUT user resolves to native — which still - // advertises the Copilot resource as not-required so the host can probe. If - // the host then silently forwards a GitHub token (the user was signed in all - // along), the acquired proxy handle re-resolves the default (rule 2: signed - // in ⇒ proxy) and flips the transport to proxy, starting the proxy. Real - // detection is used against a real `~/.claude/settings.json` credential under - // a temp home. - await withNativeSetup(async userHome => { - const { agent, proxy } = createTestContext(disposables, { - rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, - userHome, - }); - // Signed out at startup ⇒ native, Copilot advertised as not-required. - const before = { - resources: agent.getProtectedResources().map(r => ({ resource: r.resource, required: r.required })), - proxyStarts: proxy.startCalls.length, - }; + // The fix for the startup catch-22: with the exp flag on and the SDK + // reporting a Claude account, a signed-OUT user resolves to native — which + // still advertises the Copilot resource as not-required so the host can + // probe. If the host then silently forwards a GitHub token (the user was + // signed in all along), the acquired proxy handle re-resolves the default + // (rule 2: signed in ⇒ proxy) and flips the transport to proxy, starting + // the proxy. + const { agent, proxy } = createTestContext(disposables, { + rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, + nativeAccount: NATIVE_ACCOUNT, + }); + // Signed out at startup ⇒ native, Copilot advertised as not-required. + const before = { + resources: agent.getProtectedResources().map(r => ({ resource: r.resource, required: r.required })), + proxyStarts: proxy.startCalls.length, + }; - // Host probe forwards a GitHub token (user was signed in) ⇒ flip to proxy. - await agent.authenticate('https://api.github.com', 'gh-token'); - await tick(); + // Host probe forwards a GitHub token (user was signed in) ⇒ flip to proxy. + await agent.authenticate('https://api.github.com', 'gh-token'); + await tick(); - assert.deepStrictEqual({ - before, - after: { - resources: agent.getProtectedResources().map(r => ({ resource: r.resource, required: r.required })), - proxyStarts: proxy.startCalls.length, - }, - }, { - before: { - resources: [ - { resource: 'https://api.github.com', required: false }, - { resource: 'https://api.github.com/repos', required: false }, - ], - proxyStarts: 0, - }, - after: { - resources: [ - { resource: 'https://api.github.com', required: false }, - { resource: 'https://api.github.com/repos', required: false }, - ], - proxyStarts: 1, - }, - }); + assert.deepStrictEqual({ + before, + after: { + resources: agent.getProtectedResources().map(r => ({ resource: r.resource, required: r.required })), + proxyStarts: proxy.startCalls.length, + }, + }, { + before: { + resources: [ + { resource: 'https://api.github.com', required: false }, + { resource: 'https://api.github.com/repos', required: false }, + ], + proxyStarts: 0, + }, + after: { + resources: [ + { resource: 'https://api.github.com', required: false }, + { resource: 'https://api.github.com/repos', required: false }, + ], + proxyStarts: 1, + }, }); }); @@ -1463,17 +1480,52 @@ suite('ClaudeAgent', () => { }); }); - test('keeps the last known-good models when a periodic refresh fails', async () => { - const { agent, api } = createTestContext(disposables); + test('keeps the last known-good models only when every attempted source fails', async () => { + // Retention is all-or-nothing across the merged catalog: a source that + // *answers* is authoritative for its own half. Asking the SDK on every + // refresh widened where that bites — a Copilot-only user used to skip the + // native half entirely, so a CAPI hiccup held their picker; now the native + // half answers "no account" and the merged write drops the stale rows. + const { agent, api, sdk } = createTestContext(disposables); api.models = async () => [...ALL_MODELS]; await agent.authenticate('https://api.github.com', 'tok'); await agent.refreshModels(); - const modelIds = agent.models.get().map(model => model.id); + const populated = agent.models.get().map(model => model.id); + + // Only the proxy fails; the native half answers honestly (no account, so no + // models) and that answer is published. + api.models = async () => { throw new Error('transient failure'); }; + await agent.refreshModels(); + const proxyOnlyFailed = agent.models.get().map(model => model.id); + // Now nothing can answer: the catalog is held rather than blanked again. + api.models = async () => [...ALL_MODELS]; + await agent.refreshModels(); + const republished = agent.models.get().map(model => model.id); api.models = async () => { throw new Error('transient failure'); }; + sdk.supportedModelsRejection = new Error('sdk subprocess died'); await agent.refreshModels(); - assert.deepStrictEqual(agent.models.get().map(model => model.id), modelIds); + assert.deepStrictEqual({ + populated, + proxyOnlyFailed, + republished, + bothFailed: agent.models.get().map(model => model.id), + }, { + populated: [ + toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-opus-4.6'), + toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-sonnet-4.6'), + ], + proxyOnlyFailed: [], + republished: [ + toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-opus-4.6'), + toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-sonnet-4.6'), + ], + bothFailed: [ + toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-opus-4.6'), + toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-sonnet-4.6'), + ], + }); }); test('clears models when enumeration for a replacement token fails', async () => { @@ -1499,73 +1551,64 @@ suite('ClaudeAgent', () => { // superseded account to drop, and blanking would close the // `allowSignedOutWhenUsable` gate mid-startup and force the sign-in dialog // on a user who is already signing in. - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/claude-first-signin-`)); - await fs.mkdir(join(userHome.fsPath, '.claude'), { recursive: true }); - await fs.writeFile(join(userHome.fsPath, '.claude', 'settings.json'), JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-ant-test-key' } }), 'utf8'); - try { - const { agent, api, sdk } = createTestContext(disposables, { userHome }); - sdk.supportedModelsResult = [ - { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] }, - ]; - // The constructor's bootstrap refresh publishes native-only (no token yet). - for (let i = 0; i < 100 && agent.models.get().length === 0; i++) { - await tick(); - } - const bootstrap = agent.models.get().map(model => model.name); + const { agent, api, sdk } = createTestContext(disposables, { nativeAccount: NATIVE_ACCOUNT }); + sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] }, + ]; + // The constructor's bootstrap refresh publishes native-only (no token yet). + for (let i = 0; i < 100 && agent.models.get().length === 0; i++) { + await tick(); + } + const bootstrap = agent.models.get().map(model => model.name); - // Hold the CAPI enumeration open so the post-sign-in refresh is still in - // flight when we sample the catalog — that pending window is exactly what - // the renderer saw as an empty (and therefore `Unusable`) agent. - const gate = new DeferredPromise(); - api.models = async () => { await gate.p; return [...ALL_MODELS]; }; - await agent.authenticate('https://api.github.com', 'tok'); - const whileEnumerating = agent.models.get().map(model => model.name); + // Hold the CAPI enumeration open so the post-sign-in refresh is still in + // flight when we sample the catalog — that pending window is exactly what + // the renderer saw as an empty (and therefore `Unusable`) agent. + const gate = new DeferredPromise(); + api.models = async () => { await gate.p; return [...ALL_MODELS]; }; + await agent.authenticate('https://api.github.com', 'tok'); + const whileEnumerating = agent.models.get().map(model => model.name); - gate.complete(); - await agent.refreshModels(); + gate.complete(); + await agent.refreshModels(); - assert.deepStrictEqual({ - bootstrap, - whileEnumerating, - merged: agent.models.get().map(model => model.name), - }, { - bootstrap: ['Claude Sonnet 4.5'], - whileEnumerating: ['Claude Sonnet 4.5'], - merged: ['Claude Opus 4.6', 'Claude Sonnet 4.6', 'Claude Sonnet 4.5'], - }); - } finally { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - } + assert.deepStrictEqual({ + bootstrap, + whileEnumerating, + merged: agent.models.get().map(model => model.name), + }, { + bootstrap: ['Claude Sonnet 4.5'], + whileEnumerating: ['Claude Sonnet 4.5'], + merged: ['Claude Opus 4.6', 'Claude Sonnet 4.6', 'Claude Sonnet 4.5'], + }); }); - test('signed out with a local setup: models populate from supportedModels() with no proxy start and no CAPI models() call', async () => { - // Native enumeration only runs when a credential is actually present, so - // give this a real `~/.claude/settings.json` under a temp home. Signed out, - // so the proxy half of the merged catalog contributes nothing. - await withNativeSetup(async userHome => { - const { agent, proxy, api, sdk } = createTestContext(disposables, { userHome }); - let capiModelsCalls = 0; - api.models = async () => { capiModelsCalls++; return []; }; - sdk.supportedModelsResult = [ - { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] }, - ]; - // The constructor kicks off an initial native refresh; `_fetchNativeModels` - // awaits a real `mkdtemp` before enumerating, so poll until it lands. - for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { - await tick(); - } + test('signed out with an SDK-reported account: models populate from supportedModels() with no proxy start and no CAPI models() call', async () => { + // Native enumeration only publishes when the SDK's own account report says + // the user is set up, so hand it one. Signed out, so the proxy half of the + // merged catalog contributes nothing. + const { agent, proxy, api, sdk } = createTestContext(disposables, { nativeAccount: NATIVE_ACCOUNT }); + let capiModelsCalls = 0; + api.models = async () => { capiModelsCalls++; return []; }; + sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] }, + ]; + // The constructor kicks off an initial native refresh; `_fetchNativeModels` + // awaits a real `mkdtemp` before enumerating, so poll until it lands. + for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { await tick(); - assert.deepStrictEqual({ - models: agent.models.get().map(m => ({ id: m.id, name: m.name })), - proxyStarts: proxy.startCalls.length, - supportedModelsCalls: sdk.supportedModelsCallCount, - capiModelsCalls, - }, { - models: [{ id: toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), name: 'Claude Sonnet 4.5' }], - proxyStarts: 0, - supportedModelsCalls: 1, - capiModelsCalls: 0, - }); + } + await tick(); + assert.deepStrictEqual({ + models: agent.models.get().map(m => ({ id: m.id, name: m.name })), + proxyStarts: proxy.startCalls.length, + supportedModelsCalls: sdk.supportedModelsCallCount, + capiModelsCalls, + }, { + models: [{ id: toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), name: 'Claude Sonnet 4.5' }], + proxyStarts: 0, + supportedModelsCalls: 1, + capiModelsCalls: 0, }); }); @@ -1575,62 +1618,62 @@ suite('ClaudeAgent', () => { // configured to use. Published next to the Copilot-routed models it reads // as a third, unrelated choice whose target is invisible, so it is // filtered out — the model it resolves to is already its own row. - await withNativeSetup(async userHome => { - const { agent, sdk } = createTestContext(disposables, { userHome }); - sdk.supportedModelsResult = [ - { value: 'default', resolvedModel: 'claude-sonnet-4-5-20250929', displayName: 'Default (recommended)', description: '' }, - { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, - ]; - for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { - await tick(); - } + const { agent, sdk } = createTestContext(disposables, { nativeAccount: NATIVE_ACCOUNT }); + sdk.supportedModelsResult = [ + { value: 'default', resolvedModel: 'claude-sonnet-4-5-20250929', displayName: 'Default (recommended)', description: '' }, + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, + ]; + for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { await tick(); - assert.deepStrictEqual(agent.models.get().map(m => ({ id: m.id, name: m.name })), [ - { id: toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), name: 'Claude Sonnet 4.5' }, - ]); - }); + } + await tick(); + assert.deepStrictEqual(agent.models.get().map(m => ({ id: m.id, name: m.name })), [ + { id: toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), name: 'Claude Sonnet 4.5' }, + ]); }); - test('signed out without a credential publishes an empty catalog instead of the SDK static list', async () => { + test('an SDK account report of "nothing configured" publishes an empty catalog instead of the SDK static list', async () => { // `supportedModels()` answers even with no credentials (it is a static // catalog), so publishing it would advertise models that fail on first // use — and would make the type look usable-without-GitHub to the window - // gate. `/mock-home` has no `.claude` credential, so the native half is - // never attempted; signed out, neither is the proxy half. + // gate. The gate is `accountInfo()`, not the model list: both are asked + // (they are local, cheap calls against an already-present SDK) and the + // account report is what decides whether the models are published. const { agent, sdk } = createTestContext(disposables); sdk.supportedModelsResult = [ { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, ]; - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 100 && sdk.accountInfoCallCount === 0; i++) { await tick(); } + await tick(); assert.deepStrictEqual({ models: agent.models.get(), + accountInfoCalls: sdk.accountInfoCallCount, supportedModelsCalls: sdk.supportedModelsCallCount, }, { models: [], - supportedModelsCalls: 0, + accountInfoCalls: 1, + supportedModelsCalls: 1, }); }); test('native model enumeration closes the throwaway query (no leaked subprocess)', async () => { - await withNativeSetup(async userHome => { - const { sdk } = createTestContext(disposables, { userHome }); - sdk.supportedModelsResult = [ - { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, - ]; - // The constructor kicks off the initial native enumeration; wait for it. - for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { - await tick(); - } + const { sdk } = createTestContext(disposables, { nativeAccount: NATIVE_ACCOUNT }); + sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, + ]; + // The constructor kicks off the initial native enumeration; wait for it. + for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { await tick(); - assert.deepStrictEqual({ - queries: sdk.enumerationQueries.length, - closed: sdk.enumerationQueries[0]?.closeCount, - }, { - queries: 1, - closed: 1, - }); + } + await tick(); + assert.deepStrictEqual({ + queries: sdk.enumerationQueries.length, + closed: sdk.enumerationQueries[0]?.closeCount, + }, { + queries: 1, + closed: 1, }); }); @@ -1640,15 +1683,13 @@ suite('ClaudeAgent', () => { // so a session that later picks a Copilot-routed model has a started proxy // to run against — even though the model-less default // (`_defaultTransportMode`) was native right up to this call. - await withNativeSetup(async userHome => { - const { agent, proxy } = createTestContext(disposables, { - rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, - userHome, - }); - const accepted = await agent.authenticate('https://api.github.com', 'tok'); - await tick(); - assert.deepStrictEqual({ accepted, proxyStarts: proxy.startCalls.length }, { accepted: true, proxyStarts: 1 }); + const { agent, proxy } = createTestContext(disposables, { + rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, + nativeAccount: NATIVE_ACCOUNT, }); + const accepted = await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + assert.deepStrictEqual({ accepted, proxyStarts: proxy.startCalls.length }, { accepted: true, proxyStarts: 1 }); }); test('a host-default transport flip no longer proactively demands auth (sign-in defers to first send)', async () => { @@ -1658,16 +1699,14 @@ suite('ClaudeAgent', () => { // `auth/required`. Sign-in for a Copilot-routed model defers to the first // send, where `_ensureAuthenticated` throws `AHP_AUTH_REQUIRED`. Signing in // is the surviving runtime flip lever (native default → proxy default). - await withNativeSetup(async userHome => { - const { agent } = createTestContext(disposables, { - rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, - userHome, - }); - await agent.authenticate('https://api.github.com', 'tok'); - await tick(); - - assert.strictEqual((agent as IAgent).authenticationRequired, undefined); + const { agent } = createTestContext(disposables, { + rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, + nativeAccount: NATIVE_ACCOUNT, }); + await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + assert.strictEqual((agent as IAgent).authenticationRequired, undefined); }); test('construction in proxy mode does not emit auth/required', async () => { @@ -2007,6 +2046,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IProductService, FakeProductService], @@ -2081,6 +2121,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], @@ -2152,6 +2193,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], @@ -4188,6 +4230,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], @@ -4894,7 +4937,6 @@ suite('ClaudeAgent', () => { }, }; const sdk = new FakeClaudeAgentSdkService(); - sdk.canLoadWithoutDownloadResult = false; sdk.sessionList = [ { sessionId: 'a', summary: 'Session A', lastModified: 1000, createdAt: 900 }, { sessionId: 'b', summary: 'Session B', lastModified: 2000, createdAt: 1900 }, @@ -4908,6 +4950,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], @@ -4930,7 +4973,7 @@ suite('ClaudeAgent', () => { modifiedA: a?.modifiedTime, modifiedB: b?.modifiedTime, sdkCalls: sdk.listSessionsCallCount, - availabilityRequests: sdk.ensureAvailableForDiscoveryCalls, + availabilityRequests: sdk.ensureAvailableCalls, migrationChats: chatsToMigrate?.map(r => sessionIdOfChat(r.chat)), }, { count: 3, @@ -4940,7 +4983,7 @@ suite('ClaudeAgent', () => { modifiedA: 1000, modifiedB: 2000, sdkCalls: 2, - availabilityRequests: 1, + availabilityRequests: 0, migrationChats: ['a'], }); @@ -5019,6 +5062,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], @@ -5061,6 +5105,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], @@ -5110,6 +5155,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], @@ -5184,7 +5230,7 @@ suite('ClaudeAgent', () => { }); }); - test('restore reads defer while cold discovery requests SDK availability', async () => { + test('neither restore nor cold discovery pulls the SDK down', async () => { // Regression: when a materialized Claude session is restored on // startup (the renderer subscribes to the last-active session), the // host's restore path calls `getChatMetadata` -> `getSessionInfo` @@ -5192,8 +5238,8 @@ suite('ClaudeAgent', () => { // Before the fix that eagerly triggered a cold SDK download (with no // progress interest registered, so no notification) purely from // preselecting/restoring Claude — the download must only start on the - // first user message. Discovery is different: it requests background SDK - // availability so native chats are retried without a new session. + // first user message. Discovery used to be exempt and fetch in the + // background; it no longer is, since the download is the user's call. const sdk = new FakeClaudeAgentSdkService(); sdk.canLoadWithoutDownloadResult = false; sdk.sessionList = [ @@ -5208,6 +5254,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], ); @@ -5226,19 +5273,19 @@ suite('ClaudeAgent', () => { assert.deepStrictEqual({ metadata, messages, - // Restore must never touch the SDK. Discovery alone asks the SDK - // service to begin availability work. + // Nothing reachable from restore or discovery may touch the SDK + // while it is absent, whether to read it or to fetch it. getSessionInfoCalls: sdk.getSessionInfoCalls, getSessionMessagesCalls: sdk.getSessionMessagesCalls, - availabilityRequests: sdk.ensureAvailableForDiscoveryCalls, + availabilityRequests: sdk.ensureAvailableCalls, discoveredChats, }, { metadata: undefined, messages: [], getSessionInfoCalls: [], getSessionMessagesCalls: [], - availabilityRequests: 1, - discoveredChats: [1], + availabilityRequests: 0, + discoveredChats: [], }); }); @@ -5307,7 +5354,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new RecordingLogService()], - [IAgentSdkDownloader, stubAgentSdkDownloader()], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader(false)], ); const inst = disposables.add(new InstantiationService(services)); const svc = inst.createInstance(TestableClaudeAgentSdkService); @@ -5394,7 +5441,7 @@ suite('ClaudeAgent', () => { const inst = disposables.add(new InstantiationService(new ServiceCollection( [ILogService, new NullLogService()], - [IAgentSdkDownloader, stubAgentSdkDownloader()], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader(false)], ))); const svc = inst.createInstance(TestableClaudeAgentSdkService); @@ -5505,6 +5552,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new RecordingProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IProductService, FakeProductService], @@ -5560,6 +5608,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], @@ -6011,6 +6060,212 @@ suite('ClaudeAgent', () => { // #endregion }); +suite('ClaudeAgent — agent SDK setup channel', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + /** What the workbench would read off root state right now. */ + function readSetup(ctx: ITestContext) { + return readAgentSdkSetupInfos(ctx.stateManager.rootState).find(setup => setup.agent === 'claude'); + } + + /** Addresses a download request at an agent the way `IAgentSdkSetupService` does. */ + function dispatchDownload(ctx: ITestContext, agent = 'claude', request = 'req-1'): void { + ctx.configService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request } }); + } + + /** Waits for the ctor's queued publish (and any refresh it chains) to settle. */ + async function settle(): Promise { + for (let i = 0; i < 20; i++) { + await tick(); + } + } + + test('an SDK already on disk publishes `ready` plus the docs URL the banner links to', async () => { + const ctx = createTestContext(disposables); + await settle(); + + assert.deepStrictEqual(readSetup(ctx), { + agent: 'claude', + download: 'ready', + setupDocsUrl: 'https://docs.claude.com/en/docs/claude-code/setup', + // No in-app sign-in: every Claude credential is established outside the + // app, so the banner can only point at the docs. + signInProviderName: undefined, + }); + }); + + test('a cold cache publishes `notDownloaded`, which is what turns the banner into an offer', async () => { + const ctx = createTestContext(disposables); + ctx.sdk.canLoadWithoutDownloadResult = false; + await ctx.agent.refreshModels(); + await settle(); + + assert.strictEqual(readSetup(ctx)?.download, 'notDownloaded'); + }); + + test('an explicit download fetches the SDK, holds progress interest for the fetch, and ends at `ready`', async () => { + const ctx = createTestContext(disposables); + ctx.sdk.canLoadWithoutDownloadResult = false; + let releaseDownload = () => { }; + ctx.sdk.ensureAvailableGate = new Promise(resolve => { + // Releasing the gate is the moment the SDK lands on disk. + releaseDownload = () => { ctx.sdk.canLoadWithoutDownloadResult = true; resolve(); }; + }); + await ctx.agent.refreshModels(); + await settle(); + + dispatchDownload(ctx); + await settle(); + const inFlight = { + download: readSetup(ctx)?.download, + interests: [...ctx.sdkDownloader.progressInterests], + held: ctx.sdkDownloader.heldProgressInterests, + fetches: ctx.sdk.ensureAvailableCalls, + }; + + releaseDownload(); + await settle(); + + assert.deepStrictEqual({ inFlight, after: readSetup(ctx)?.download, held: ctx.sdkDownloader.heldProgressInterests }, { + inFlight: { download: 'downloading', interests: ['claude'], held: 1, fetches: 1 }, + after: 'ready', + held: 0, + }); + }); + + test('a download that lands stays `downloading` until the catalog does, so the banner never flashes "no account"', async () => { + const ctx = createTestContext(disposables, { nativeAccount: NATIVE_ACCOUNT }); + // Let the constructor's own refresh reach enumeration before the gate below + // goes up, so the only blocked enumeration is the download's. + for (let i = 0; i < 100 && ctx.sdk.supportedModelsCallCount === 0; i++) { + await tick(); + } + ctx.sdk.canLoadWithoutDownloadResult = false; + await ctx.agent.refreshModels(); + await settle(); + + ctx.sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] }, + ]; + let releaseEnumeration = () => { }; + ctx.sdk.supportedModelsGate = new Promise(resolve => { releaseEnumeration = resolve; }); + // Resolving the fetch is the moment the SDK lands on disk. + ctx.sdk.ensureAvailableGate = Promise.resolve().then(() => { ctx.sdk.canLoadWithoutDownloadResult = true; }); + const enumerationsBefore = ctx.sdk.supportedModelsCallCount; + + dispatchDownload(ctx); + for (let i = 0; i < 100 && ctx.sdk.supportedModelsCallCount === enumerationsBefore; i++) { + await tick(); + } + const enumerating = { download: readSetup(ctx)?.download, models: ctx.agent.models.get().length }; + + releaseEnumeration(); + for (let i = 0; i < 100 && ctx.agent.models.get().length === 0; i++) { + await tick(); + } + await settle(); + + assert.deepStrictEqual({ enumerating, after: readSetup(ctx)?.download, models: ctx.agent.models.get().length }, { + // `ready` while the catalog is still empty is precisely how the window + // renders "we looked and found no account". + enumerating: { download: 'downloading', models: 0 }, + after: 'ready', + models: 1, + }); + }); + + test('the request key is cleared as it is consumed, so an identical later press still lands', async () => { + const ctx = createTestContext(disposables); + ctx.sdk.canLoadWithoutDownloadResult = false; + await ctx.agent.refreshModels(); + await settle(); + + dispatchDownload(ctx, 'claude', 'press-1'); + await settle(); + const consumed = ctx.configService.getRootConfigValues()[AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]; + + dispatchDownload(ctx, 'claude', 'press-2'); + await settle(); + + assert.deepStrictEqual({ consumed, fetches: ctx.sdk.ensureAvailableCalls }, { consumed: undefined, fetches: 2 }); + }); + + test('a request addressed to another agent is ignored', async () => { + const ctx = createTestContext(disposables); + ctx.sdk.canLoadWithoutDownloadResult = false; + await ctx.agent.refreshModels(); + await settle(); + + dispatchDownload(ctx, 'codex'); + await settle(); + + assert.deepStrictEqual({ + fetches: ctx.sdk.ensureAvailableCalls, + // Left in place for the agent it names, rather than consumed by this one. + key: ctx.configService.getRootConfigValues()[AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY], + }, { + fetches: 0, + key: { agent: 'codex', request: 'req-1' }, + }); + }); + + test('a failed download releases the progress interest and stops claiming to be downloading', async () => { + const ctx = createTestContext(disposables); + ctx.sdk.canLoadWithoutDownloadResult = false; + ctx.sdk.ensureAvailableRejection = new Error('CDN unreachable'); + await ctx.agent.refreshModels(); + await settle(); + + dispatchDownload(ctx); + await settle(); + + assert.deepStrictEqual({ + download: readSetup(ctx)?.download, + held: ctx.sdkDownloader.heldProgressInterests, + }, { + download: 'notDownloaded', + held: 0, + }); + }); + + test('chat discovery waits for the SDK rather than fetching it, and runs again once it lands', async () => { + // The catalog of migratable Claude Code chats lives inside the SDK, so + // discovery used to fetch one at startup — hundreds of megabytes for a + // user still being asked whether they want it. + const ctx = createTestContext(disposables); + ctx.sdk.canLoadWithoutDownloadResult = false; + ctx.sdk.sessionList = [{ sessionId: 'from-claude-code', summary: 'An existing chat', lastModified: 1000, createdAt: 900 }]; + // Subscribing is what starts discovery. + const discovered: number[] = []; + disposables.add(ctx.agent.onDidDiscoverChats(chats => discovered.push(chats.length))); + await settle(); + const cold = { + discovered: [...discovered], + // `undefined` is "ask again later", as distinct from "nothing to migrate". + migratable: await ctx.agent.listChatsToMigrate(), + fetches: ctx.sdk.ensureAvailableCalls, + }; + + let landed = () => { }; + ctx.sdk.ensureAvailableGate = new Promise(resolve => { + landed = () => { ctx.sdk.canLoadWithoutDownloadResult = true; resolve(); }; + }); + dispatchDownload(ctx); + await settle(); + const inFlight = [...discovered]; + + landed(); + await settle(); + + assert.deepStrictEqual({ cold, inFlight, after: discovered, migratable: await ctx.agent.listChatsToMigrate() }, { + cold: { discovered: [], migratable: undefined, fetches: 0 }, + inFlight: [], + after: [1], + migratable: [], + }); + }); +}); + suite('ClaudeAgent — per-session provider', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -6202,56 +6457,59 @@ suite('ClaudeAgent — per-session provider', () => { // the host-global default flips underneath it. Otherwise signing into // Copilot mid-conversation would silently drag a running native // (BYO-Anthropic) session onto the proxy on its next rebind. - await withNativeSetup(async userHome => { - const ctx = createTestContext(disposables, { - rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, - userHome, - }); + const ctx = createTestContext(disposables, { + rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, + nativeAccount: NATIVE_ACCOUNT, + }); + // The host default only becomes native once the SDK has been *asked* about + // the account — that answer is what `_defaultTransportMode` reads. Await a + // full refresh, or this materializes on the proxy default and throws + // `AHP_AUTH_REQUIRED` while signed out. + await ctx.agent.refreshModels(); + + // Materialize a native session while signed out: turn-1 starts the + // subprocess (system_init) then crashes mid-stream, leaving it needing a + // warm rebind on the next send. + const created = await createSession(ctx.agent, { workingDirectories: [URI.file('/workspace')], model: { id: 'claude-sonnet-4-5-20250929' } }); + const sid = created.sdkSessionId; + ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid)]; + ctx.sdk.queryAdvance = async (i: number) => { if (i === 1) { throw new Error('subprocess crashed'); } }; + await assert.rejects( + ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, undefined, 'turn-1', undefined, undefined, chatContext(defaultChatUri(created.session))), + (err: Error) => err.message.includes('subprocess crashed'), + ); + ctx.sdk.queryAdvance = undefined; - // Materialize a native session while signed out: turn-1 starts the - // subprocess (system_init) then crashes mid-stream, leaving it needing a - // warm rebind on the next send. - const created = await createSession(ctx.agent, { workingDirectories: [URI.file('/workspace')], model: { id: 'claude-sonnet-4-5-20250929' } }); - const sid = created.sdkSessionId; - ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid)]; - ctx.sdk.queryAdvance = async (i: number) => { if (i === 1) { throw new Error('subprocess crashed'); } }; - await assert.rejects( - ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, undefined, 'turn-1', undefined, undefined, chatContext(defaultChatUri(created.session))), - (err: Error) => err.message.includes('subprocess crashed'), - ); - ctx.sdk.queryAdvance = undefined; - - // Sign into Copilot: this flips the host default native→proxy and - // acquires a proxy handle. - await ctx.agent.authenticate('https://api.github.com', 'tok'); - await tick(); + // Sign into Copilot: this flips the host default native→proxy and + // acquires a proxy handle. + await ctx.agent.authenticate('https://api.github.com', 'tok'); + await tick(); - // Positive control that the flip is live: a brand-new session now - // materializes on the proxy (carrying the per-session bearer token). - const fresh = await createSession(ctx.agent, { workingDirectories: [URI.file('/fresh')], model: { id: 'claude-opus-4.6' } }); - const freshSid = fresh.sdkSessionId; - ctx.sdk.nextQueryMessages = [makeSystemInitMessage(freshSid), makeResultSuccess(freshSid)]; - await ctx.agent.chats.sendMessage(defaultChatUri(fresh.session), 'hi', undefined, undefined, 'fresh-1', undefined, undefined, chatContext(defaultChatUri(fresh.session))); + // Positive control that the flip is live: a brand-new session now + // materializes on the proxy (carrying the per-session bearer token). + const fresh = await createSession(ctx.agent, { workingDirectories: [URI.file('/fresh')], model: { id: 'claude-opus-4.6' } }); + const freshSid = fresh.sdkSessionId; + ctx.sdk.nextQueryMessages = [makeSystemInitMessage(freshSid), makeResultSuccess(freshSid)]; + await ctx.agent.chats.sendMessage(defaultChatUri(fresh.session), 'hi', undefined, undefined, 'fresh-1', undefined, undefined, chatContext(defaultChatUri(fresh.session))); - // Recover the ORIGINAL session: the next send warm-rebuilds it (resume), - // and that rebuild must stay native despite the flipped host default. - ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'recover', undefined, undefined, 'turn-2', undefined, undefined, chatContext(defaultChatUri(created.session))); + // Recover the ORIGINAL session: the next send warm-rebuilds it (resume), + // and that rebuild must stay native despite the flipped host default. + ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'recover', undefined, undefined, 'turn-2', undefined, undefined, chatContext(defaultChatUri(created.session))); - assert.deepStrictEqual({ - originalMaterializeNative: proxyAuthTokenOf(ctx.sdk.capturedStartupOptions[0]) === undefined, - freshSessionProxy: proxyAuthTokenOf(ctx.sdk.capturedStartupOptions[1]) !== undefined, - rebuild: { - resume: ctx.sdk.capturedStartupOptions[2]?.resume, - stayedNative: proxyAuthTokenOf(ctx.sdk.capturedStartupOptions[2]) === undefined, - }, - totalStartups: ctx.sdk.startupCallCount, - }, { - originalMaterializeNative: true, - freshSessionProxy: true, - rebuild: { resume: sid, stayedNative: true }, - totalStartups: 3, - }); + assert.deepStrictEqual({ + originalMaterializeNative: proxyAuthTokenOf(ctx.sdk.capturedStartupOptions[0]) === undefined, + freshSessionProxy: proxyAuthTokenOf(ctx.sdk.capturedStartupOptions[1]) !== undefined, + rebuild: { + resume: ctx.sdk.capturedStartupOptions[2]?.resume, + stayedNative: proxyAuthTokenOf(ctx.sdk.capturedStartupOptions[2]) === undefined, + }, + totalStartups: ctx.sdk.startupCallCount, + }, { + originalMaterializeNative: true, + freshSessionProxy: true, + rebuild: { resume: sid, stayedNative: true }, + totalStartups: 3, }); }); @@ -6283,63 +6541,73 @@ suite('ClaudeAgent — per-session provider', () => { }); }); - test('the Copilot resource is optional only when the opt-in AND a BYO-Anthropic credential are both present', async () => { - // Full 2x2 so no single input can carry the result on its own: in - // particular `optInOnNoCredential` is the regression this guards — the - // requirement must survive the opt-in being on when the user has no - // Anthropic credential to run on. - const copilotRequired = (agent: ClaudeAgent) => - agent.getProtectedResources().find(r => r.resource === 'https://api.github.com')?.required; - const optIn = { rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true } }; - await withNativeSetup(async userHome => { - assert.deepStrictEqual({ - optInOnNoCredential: copilotRequired(createTestContext(disposables, { ...optIn }).agent), - optInOnWithCredential: copilotRequired(createTestContext(disposables, { ...optIn, userHome }).agent), - optInOffWithCredential: copilotRequired(createTestContext(disposables, { userHome }).agent), - optInOffNoCredential: copilotRequired(createTestContext(disposables).agent), - }, { - optInOnNoCredential: true, - optInOnWithCredential: false, - optInOffWithCredential: true, - optInOffNoCredential: true, + test('the Copilot resource is unconditionally optional, whatever the opt-in or the SDK account report says', async () => { + // The load-bearing assertion of the whole feature. `required: false` is what + // stops `resolveAgentAuthRequirement` answering `GitHub` for this session + // type; when *every* type answers `GitHub`, `resolveSignedOutWindowGate` + // puts a non-dismissible sign-in wall over the entire Agents window. + // + // The full 2x2 is asserted because the behavior it replaces was a 2x2 with + // three `true`s in it: neither the opt-in nor the account report may bring + // the requirement back. Even with no Claude account the type reads as + // `Unusable` rather than `GitHub`, which is what opens the window. The flag + // is absent by design — it gates this one level up, in + // `resolveSignedOutWindowGate`. + const advertisedRequirement = async (inputs: { optIn: boolean; account: boolean }) => { + const { agent } = createTestContext(disposables, { + ...(inputs.optIn ? { rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true } } : {}), + ...(inputs.account ? { nativeAccount: NATIVE_ACCOUNT } : {}), }); + // Let the account probe land. The old answer keyed on exactly this fact, + // so without the wait both halves of the matrix would be asserting the + // same pre-probe state and the test would pass vacuously. + await agent.refreshModels(); + return agent.getProtectedResources().find(r => r.resource === 'https://api.github.com')?.required; + }; + + assert.deepStrictEqual({ + optInOnWithAccount: await advertisedRequirement({ optIn: true, account: true }), + optInOffWithAccount: await advertisedRequirement({ optIn: false, account: true }), + optInOnNoAccount: await advertisedRequirement({ optIn: true, account: false }), + optInOffNoAccount: await advertisedRequirement({ optIn: false, account: false }), + }, { + optInOnWithAccount: false, + optInOffWithAccount: false, + optInOnNoAccount: false, + optInOffNoAccount: false, }); }); test('the Copilot resource is advertised, never dropped, so the silent token probe survives', async () => { // `authenticateProtectedResources` matches on `resource` and ignores // `required`, so dropping it would break sign-in forwarding. - await withNativeSetup(async userHome => { - const optional = createTestContext(disposables, { - rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, - userHome, - }).agent.getProtectedResources(); - assert.deepStrictEqual(optional.map(r => ({ resource: r.resource, required: r.required })), [ - { resource: 'https://api.github.com', required: false }, - { resource: 'https://api.github.com/repos', required: false }, - ]); - }); + const optional = createTestContext(disposables, { + rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, + nativeAccount: NATIVE_ACCOUNT, + }).agent.getProtectedResources(); + assert.deepStrictEqual(optional.map(r => ({ resource: r.resource, required: r.required })), [ + { resource: 'https://api.github.com', required: false }, + { resource: 'https://api.github.com/repos', required: false }, + ]); }); test('merged catalog lists both providers, each id provider-qualified', async () => { - await withNativeSetup(async userHome => { - const { agent, api, sdk } = createTestContext(disposables, { userHome }); - api.models = async () => [CLAUDE_OPUS]; - sdk.supportedModelsResult = [ - { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, - ]; - await agent.authenticate('https://api.github.com', 'tok'); - await agent.refreshModels(); - await tick(); + const { agent, api, sdk } = createTestContext(disposables, { nativeAccount: NATIVE_ACCOUNT }); + api.models = async () => [CLAUDE_OPUS]; + sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, + ]; + await agent.authenticate('https://api.github.com', 'tok'); + await agent.refreshModels(); + await tick(); - // Proxy first (preserves `models[0]`-is-default), then native; each id is - // rewritten to its provider-qualified form so the picked row carries its - // transport. - assert.deepStrictEqual(agent.models.get().map(m => ({ id: m.id, name: m.name })), [ - { id: toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-opus-4.6'), name: 'Claude Opus 4.6' }, - { id: toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), name: 'Claude Sonnet 4.5' }, - ]); - }); + // Proxy first (preserves `models[0]`-is-default), then native; each id is + // rewritten to its provider-qualified form so the picked row carries its + // transport. + assert.deepStrictEqual(agent.models.get().map(m => ({ id: m.id, name: m.name })), [ + { id: toClaudeModelSelectionId(CLAUDE_PROVIDER_COPILOT, 'claude-opus-4.6'), name: 'Claude Opus 4.6' }, + { id: toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), name: 'Claude Sonnet 4.5' }, + ]); }); test('per-session transport gates on the picked model, not a global mode', async () => { @@ -6402,23 +6670,21 @@ suite('ClaudeAgent — per-session provider', () => { // unconditionally — a signed-out window with a native setup has no GitHub // token to trigger a proxy refresh, so without this it would never populate // its picker and dead-end. No `authenticate`, no explicit `refreshModels`. - await withNativeSetup(async userHome => { - const { agent, sdk } = createTestContext(disposables, { userHome }); - sdk.supportedModelsResult = [ - { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, - ]; - // The constructor kicks off the initial merged enumeration; wait for it. - for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { - await tick(); - } + const { agent, sdk } = createTestContext(disposables, { nativeAccount: NATIVE_ACCOUNT }); + sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, + ]; + // The constructor kicks off the initial merged enumeration; wait for it. + for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { await tick(); + } + await tick(); - // Signed out → the proxy half contributes nothing; only the native - // models appear, provider-qualified. - assert.deepStrictEqual(agent.models.get().map(m => m.id), [ - toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), - ]); - }); + // Signed out → the proxy half contributes nothing; only the native + // models appear, provider-qualified. + assert.deepStrictEqual(agent.models.get().map(m => m.id), [ + toClaudeModelSelectionId(CLAUDE_PROVIDER_ANTHROPIC, 'claude-sonnet-4-5-20250929'), + ]); }); test('a failing proxy start does not fail sign-in', async () => { @@ -6520,6 +6786,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { [ICopilotApiService, new FakeCopilotApiService()], [IAgentHostAuthenticationService, disposables.add(new FakeAgentHostAuthenticationService())], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, new FakeAgentPluginManager()], [ISessionDataService, sessionData], ); @@ -8123,6 +8390,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); const otelService = new RecordingOTelService(); + const sdkDownloader = new RecordingAgentSdkDownloader(); const services = new ServiceCollection( [IFileService, fileService], [INativeEnvironmentService, { userHome: URI.file('/mock-home') } as INativeEnvironmentService], @@ -8131,6 +8399,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, sdkDownloader], [IAgentPluginManager, pluginManager], [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], @@ -8176,7 +8445,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { ?? (chat.scheme === 'ahp-chat' ? URI.parse(parseRequiredSessionUriFromChatUri(chat.toString())) : chat); return sendMessage(chat, prompt, workingDirectoriesOrDirectory, attachments, turnId, senderClientId, clientType, { ...createAgentChatContext(stateManager, session, chat), ...explicit }); }; - return { agent, proxy, api, sdk, sessionData, stateManager, configService, otelService, instantiationService, fileService }; + return { agent, proxy, api, sdk, sessionData, stateManager, configService, otelService, instantiationService, fileService, sdkDownloader }; } function publishReducerCustomizations(stateManager: AgentHostStateManager, session: URI, customizations: readonly Customization[]): void { diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts index 922ca21121bc4..6f63f4bebd1ae 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts @@ -45,7 +45,7 @@ class FakeSdkService implements IClaudeAgentSdkService { async listSessions(): Promise { return []; } async canLoadWithoutDownload(): Promise { return true; } - async ensureAvailableForDiscovery(): Promise { } + async ensureAvailable(): Promise { } async getSessionInfo(_id: string): Promise { return undefined; } async startup(_p: { options: Options; initializeTimeoutMs?: number }): Promise { throw new Error('not used'); } async query(_params: { prompt: string | AsyncIterable; options?: Options }): Promise { throw new Error('not used'); } diff --git a/src/vs/platform/agentHost/test/node/claudeTransportMode.test.ts b/src/vs/platform/agentHost/test/node/claudeTransportMode.test.ts index 537fd852b08ec..80bf009c10226 100644 --- a/src/vs/platform/agentHost/test/node/claudeTransportMode.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeTransportMode.test.ts @@ -3,12 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { AccountInfo } from '@anthropic-ai/claude-agent-sdk'; import assert from 'assert'; -import * as fs from 'fs'; -import * as os from 'os'; -import { join } from '../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { detectExistingClaudeSetup, resolveClaudeTransportMode } from '../../node/claude/claudeTransportMode.js'; +import { isClaudeAccountSetUp, resolveClaudeTransportMode } from '../../node/claude/claudeTransportMode.js'; suite('claudeTransportMode', () => { @@ -39,97 +37,39 @@ suite('claudeTransportMode', () => { }); }); - suite('detectExistingClaudeSetup', () => { - // The credential env is injected explicitly (never `process.env`), so the - // ambient machine's real credentials can't leak into the assertions and no - // global is mutated. The file source is exercised through a real temp home. - let homeDir: string; - - setup(async () => { - homeDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'claude-setup-detect-')); - }); - - teardown(async () => { - await fs.promises.rm(homeDir, { recursive: true, force: true }); - }); - - function writeSettings(contents: string): void { - const dir = join(homeDir, '.claude'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(join(dir, 'settings.json'), contents, 'utf8'); - } - - test('detects each env-var credential (and ignores a blank value)', () => { - assert.deepStrictEqual({ - none: detectExistingClaudeSetup(homeDir, {}), - apiKey: detectExistingClaudeSetup(homeDir, { ANTHROPIC_API_KEY: 'sk-ant-api-x' }), - authToken: detectExistingClaudeSetup(homeDir, { ANTHROPIC_AUTH_TOKEN: 'sk-ant-auth-x' }), - baseUrl: detectExistingClaudeSetup(homeDir, { ANTHROPIC_BASE_URL: 'https://gateway.example/v1' }), - oauthToken: detectExistingClaudeSetup(homeDir, { CLAUDE_CODE_OAUTH_TOKEN: 'sk-ant-oat-x' }), - emptyValue: detectExistingClaudeSetup(homeDir, { ANTHROPIC_API_KEY: '' }), - whitespaceValue: detectExistingClaudeSetup(homeDir, { ANTHROPIC_API_KEY: ' ' }), - }, { none: false, apiKey: true, authToken: true, baseUrl: true, oauthToken: true, emptyValue: false, whitespaceValue: false }); - }); - - test('detects a credential in the settings.json env block (empty env injected)', () => { - const results: Record = {}; - writeSettings(JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-ant-api-x' } })); - results.apiKey = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-ant-auth-x' } })); - results.authToken = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://gateway.example/v1' } })); - results.baseUrl = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ env: { CLAUDE_CODE_OAUTH_TOKEN: 'sk-ant-oat-x' } })); - results.oauthToken = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ env: { ANTHROPIC_API_KEY: '' } })); - results.emptyValue = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ env: { ANTHROPIC_API_KEY: ' ' } })); - results.whitespaceValue = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ model: 'claude-sonnet-4-5' })); - results.noEnvBlock = detectExistingClaudeSetup(homeDir, {}); - writeSettings('not json'); - results.malformed = detectExistingClaudeSetup(homeDir, {}); - // The tolerant parser salvages a partial object from a truncated file - // rather than failing, so the credential it recovers must not count — - // the CLI reading the same file would not get one. - writeSettings('{ "env": { "ANTHROPIC_API_KEY": "sk-ant-api-x"'); - results.truncated = detectExistingClaudeSetup(homeDir, {}); - // Read with the same tolerant parser VS Code uses for every other - // hand-edited config, so comments and a trailing comma still resolve. - writeSettings('{\n\t// my key\n\t"env": { "ANTHROPIC_API_KEY": "sk-ant-api-x", },\n}'); - results.jsonc = detectExistingClaudeSetup(homeDir, {}); - - assert.deepStrictEqual(results, { apiKey: true, authToken: true, baseUrl: true, oauthToken: true, emptyValue: false, whitespaceValue: false, noEnvBlock: false, malformed: false, truncated: false, jsonc: true }); - }); - - test('detects the top-level apiKeyHelper alongside unrecognized settings', () => { - const results: Record = {}; - writeSettings(JSON.stringify({ apiKeyHelper: '/bin/mint-key.sh' })); - results.helper = detectExistingClaudeSetup(homeDir, {}); - // A real settings file carries keys the validator doesn't declare; they - // must be ignored rather than fail validation for the whole file. - writeSettings(JSON.stringify({ apiKeyHelper: '/bin/mint-key.sh', model: 'claude-sonnet-4-5', permissions: { allow: [] } })); - results.helperAmongOthers = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ apiKeyHelper: '' })); - results.emptyValue = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ apiKeyHelper: 42 })); - results.wrongType = detectExistingClaudeSetup(homeDir, {}); - - assert.deepStrictEqual(results, { helper: true, helperAmongOthers: true, emptyValue: false, wrongType: false }); - }); - - test('a malformed source never masks a usable one', () => { - const results: Record = {}; - writeSettings(JSON.stringify({ apiKeyHelper: '/bin/mint-key.sh', env: { ANTHROPIC_API_KEY: 42 } })); - results.helperWithMistypedEnvKey = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ apiKeyHelper: 42, env: { ANTHROPIC_API_KEY: 'sk-ant-api-x' } })); - results.apiKeyWithMistypedHelper = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-ant-api-x', ANTHROPIC_BASE_URL: 8080 } })); - results.apiKeyWithMistypedSibling = detectExistingClaudeSetup(homeDir, {}); - writeSettings(JSON.stringify({ apiKeyHelper: '/bin/mint-key.sh', env: 'not an object' })); - results.helperWithNonObjectEnv = detectExistingClaudeSetup(homeDir, {}); - - assert.deepStrictEqual(results, { helperWithMistypedEnvKey: true, apiKeyWithMistypedHelper: true, apiKeyWithMistypedSibling: true, helperWithNonObjectEnv: true }); + suite('isClaudeAccountSetUp', () => { + // Every row is a shape observed from a real `accountInfo()` probe — the + // rule exists to match what the SDK actually reports. + const cases: readonly (readonly [name: string, account: AccountInfo | undefined, expected: boolean])[] = [ + // The SDK could not be asked at all (not downloaded, or the query + // failed). Publishing models we cannot back is the bug being fixed. + ['no report at all', undefined, false], + // Measured with an empty `HOME` and a stripped environment. The + // real-looking `apiProvider` here is exactly why it is not a presence + // signal — this user has nothing configured. + ['nothing configured', { tokenSource: 'none', apiProvider: 'firstParty' }, false], + // Same verdict without the provider field, so absence is not read as + // third-party. + ['nothing configured, no provider field', { tokenSource: 'none' }, false], + ['empty report', {}, false], + // `claude login` / `CLAUDE_CODE_OAUTH_TOKEN` — the keychain case no + // filesystem check could ever see. + ['oauth token', { tokenSource: 'ANTHROPIC_AUTH_TOKEN', apiProvider: 'firstParty' }, true], + // An API key reports through `apiKeySource` and leaves `tokenSource` + // at its `'none'` sentinel, so testing `tokenSource` alone misses it. + ['api key', { tokenSource: 'none', apiKeySource: 'ANTHROPIC_API_KEY', apiProvider: 'firstParty' }, true], + // The rows a later "simplification" silently breaks: for third-party + // backends the SDK documents the credential fields as absent, because + // auth is external (AWS creds, gcloud ADC). + ['third-party backend (bedrock)', { apiProvider: 'bedrock' }, true], + ['third-party backend (vertex)', { apiProvider: 'vertex' }, true], + ['enterprise gateway', { apiProvider: 'gateway' }, true], + ]; + + test('maps observed SDK account reports onto one set-up answer', () => { + assert.deepStrictEqual( + Object.fromEntries(cases.map(([name, account]) => [name, isClaudeAccountSetUp(account)])), + Object.fromEntries(cases.map(([name, , expected]) => [name, expected]))); }); }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index 893542b48e4fc..ad65982286323 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -379,23 +379,27 @@ suite('CodexAgent', () => { }); }); - test('cold native discovery waits for the SDK and emits through one deterministic path', async () => { - const sdkReady = new DeferredPromise(); + test('cold native discovery waits for the SDK rather than fetching it, and runs again once it lands', async () => { const onDidDiscoverChats = new Emitter(); const discoveredChats: number[] = []; const listener = onDidDiscoverChats.event(chats => discoveredChats.push(chats.length)); - const startDiscovery = (CodexAgent.prototype as unknown as { - _startCodexChatDiscovery(this: { - _codexChatDiscovery: Promise | undefined; - _resolveSdkRoot(): Promise; - _emitCodexChats(): Promise; - _logService: { warn(message: string): void }; - }): Promise; - })._startCodexChatDiscovery; - const harness = { - _logService: { warn: () => { } }, - _codexChatDiscovery: undefined as Promise | undefined, - _resolveSdkRoot: () => sdkReady.p, + type DiscoveryHarness = { + _codexChatDiscovery: Promise | undefined; + _isSdkResolvableWithoutDownload(): Promise; + _emitCodexChats(): Promise; + _startCodexChatDiscovery(): Promise; + _logService: { warn(message: string): void; info(message: string): void }; + }; + const discovery = CodexAgent.prototype as unknown as { + _startCodexChatDiscovery(this: DiscoveryHarness): Promise; + _restartChatDiscovery(this: DiscoveryHarness): void; + }; + let sdkIsLocal = false; + const harness: DiscoveryHarness = { + _logService: { warn: () => { }, info: () => { } }, + _codexChatDiscovery: undefined, + _isSdkResolvableWithoutDownload: async () => sdkIsLocal, + _startCodexChatDiscovery: () => discovery._startCodexChatDiscovery.call(harness), _emitCodexChats: async () => { onDidDiscoverChats.fire([{ chat: URI.parse('agenthost-chat://codex/session/default'), @@ -407,13 +411,15 @@ suite('CodexAgent', () => { }, }; - const discovery = startDiscovery.call(harness); - assert.deepStrictEqual(discoveredChats, []); + await discovery._startCodexChatDiscovery.call(harness); + const cold = [...discoveredChats]; - sdkReady.complete('/sdk-root'); - await discovery; + // What the explicit download does on its way out. + sdkIsLocal = true; + discovery._restartChatDiscovery.call(harness); + await harness._codexChatDiscovery; - assert.deepStrictEqual(discoveredChats, [1]); + assert.deepStrictEqual({ cold, after: discoveredChats }, { cold: [], after: [1] }); listener.dispose(); onDidDiscoverChats.dispose(); }); @@ -429,36 +435,31 @@ suite('CodexAgent', () => { ]; const listChatsToMigrate = (CodexAgent.prototype as unknown as { listChatsToMigrate(this: { - _resolveSdkRoot(): Promise; + _isSdkResolvableWithoutDownload(): Promise; _listCodexChats(): Promise; _isKnownCodexChat(chat: (typeof chats)[number]): Promise; - _logService: NullLogService; + _logService: { info(message: string): void }; }): Promise; }).listChatsToMigrate; - - const result = await listChatsToMigrate.call({ - _resolveSdkRoot: async () => '/sdk-root', + // Deferred while the SDK is absent: the catalog it reads lives inside one, + // and fetching it is the user's call. + let sdkIsLocal = false; + const harness = { + _logService: { info: () => { } }, + _isSdkResolvableWithoutDownload: async () => sdkIsLocal, _listCodexChats: async () => chats, - _isKnownCodexChat: async chat => { + _isKnownCodexChat: async (chat: (typeof chats)[number]) => { const id = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chat.chat))); return id !== 'unknown-external'; }, - _logService: new NullLogService(), - }); + }; - assert.deepStrictEqual(result, chats.slice(0, 2)); - assert.deepStrictEqual(await listChatsToMigrate.call({ - _resolveSdkRoot: async () => '/sdk-root', - _listCodexChats: async () => [], - _isKnownCodexChat: async () => false, - _logService: new NullLogService(), - }), []); - assert.deepStrictEqual(await listChatsToMigrate.call({ - _resolveSdkRoot: async () => { throw new Error('SDK unavailable'); }, - _listCodexChats: async () => [], - _isKnownCodexChat: async () => false, - _logService: new NullLogService(), - }), undefined); + const cold = await listChatsToMigrate.call(harness); + sdkIsLocal = true; + const result = await listChatsToMigrate.call(harness); + const empty = await listChatsToMigrate.call({ ...harness, _listCodexChats: async () => [], _isKnownCodexChat: async () => false }); + + assert.deepStrictEqual({ cold, result, empty }, { cold: undefined, result: chats.slice(0, 2), empty: [] }); }); test('native discovery emits only unknown Codex chats as external', async () => { diff --git a/src/vs/platform/agentHost/test/node/codex/codexLocalAuth.test.ts b/src/vs/platform/agentHost/test/node/codex/codexLocalAuth.test.ts deleted file mode 100644 index d970e1e69e25c..0000000000000 --- a/src/vs/platform/agentHost/test/node/codex/codexLocalAuth.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { promises as fs } from 'fs'; -import os from 'os'; -import { join } from '../../../../../base/common/path.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { detectExistingCodexChatGPTSetup } from '../../../node/codex/codexLocalAuth.js'; - -suite('Codex local auth detection', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - async function withCodexHome(run: (codexHome: string, env: NodeJS.ProcessEnv) => Promise): Promise { - const codexHome = await fs.mkdtemp(join(os.tmpdir(), 'vscode-codex-auth-test-')); - try { - await run(codexHome, { CODEX_HOME: codexHome }); - } finally { - await fs.rm(codexHome, { recursive: true, force: true }); - } - } - - test('recognizes persisted ChatGPT token modes', async () => withCodexHome(async (codexHome, env) => { - for (const auth of [ - { auth_mode: 'chatgpt', tokens: { access_token: 'access', refresh_token: 'refresh' } }, - { auth_mode: 'chatgptAuthTokens', tokens: { access_token: 'access' } }, - { auth_mode: 'personalAccessToken', personal_access_token: 'pat' }, - { tokens: { access_token: 'legacy-access' } }, - ]) { - await fs.writeFile(join(codexHome, 'auth.json'), JSON.stringify(auth)); - assert.strictEqual(detectExistingCodexChatGPTSetup('/unused', env), true); - } - })); - - test('rejects non-human and malformed auth states', async () => withCodexHome(async (codexHome, env) => { - for (const auth of [ - { auth_mode: 'apiKey', OPENAI_API_KEY: 'sk-test' }, - { auth_mode: 'bedrockApiKey', bedrock_api_key: { secret: 'secret' } }, - { auth_mode: 'agentIdentity', agent_identity: { token: 'token' } }, - { auth_mode: 'chatgpt', tokens: { access_token: '', refresh_token: '' } }, - { tokens: null }, - ]) { - await fs.writeFile(join(codexHome, 'auth.json'), JSON.stringify(auth)); - assert.strictEqual(detectExistingCodexChatGPTSetup('/unused', env), false); - } - await fs.writeFile(join(codexHome, 'auth.json'), '{'); - assert.strictEqual(detectExistingCodexChatGPTSetup('/unused', env), false); - })); - - test('honors an explicit Codex home override', async () => withCodexHome(async (codexHome, env) => { - await fs.writeFile(join(codexHome, 'auth.json'), JSON.stringify({ - auth_mode: 'personalAccessToken', - personal_access_token: 'pat', - })); - assert.strictEqual(detectExistingCodexChatGPTSetup('/unused', {}, codexHome), true); - })); -}); diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index 52ee076207d0d..b639fbba1726d 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -5,10 +5,7 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; -import * as fs from 'fs'; -import * as os from 'os'; import { Event } from '../../../../../base/common/event.js'; -import { join } from '../../../../../base/common/path.js'; import type { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -22,7 +19,9 @@ import { IAgentHostCustomizationEnablementService } from '../../../node/agentHos import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { IAgentHostSessionTitleSignal } from '../../../node/agentHostSessionTitleSignal.js'; import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js'; +import { RecordingAgentSdkDownloader } from '../testAgentSdkDownloader.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../../common/agentSdkSetup.js'; import { CodexAgent, toCodexModelSelectionId } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; @@ -33,7 +32,20 @@ import { IAgentHostOTelService } from '../../../common/otel/agentHostOTelService import { AgentHostConfigKey } from '../../../common/agentHostCustomizationConfig.js'; import { createNoopCustomizationEnablementService } from '../testCustomizationEnablementService.js'; -function createAgent(disposables: Pick, models: () => Promise, rootConfig: Record = {}, userHome = '/tmp'): CodexAgent { +interface ITestAgentContext { + readonly agent: CodexAgent; + readonly stateManager: AgentHostStateManager; + readonly configurationService: AgentConfigurationService; + readonly sdkDownloader: RecordingAgentSdkDownloader; +} + +/** + * The downloader defaults to "SDK already on disk", which is what makes these + * tests deterministic — otherwise the answer depends on whether the machine + * running the suite has `@openai/codex` in `node_modules`. Tests wanting the + * cold case override `_isSdkResolvableWithoutDownload` directly. + */ +function createAgentContext(disposables: Pick, models: () => Promise, rootConfig: Record = {}, sdkDownloader = new RecordingAgentSdkDownloader()): ITestAgentContext { const instantiationService = new TestInstantiationService(); const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); @@ -45,242 +57,235 @@ function createAgent(disposables: Pick, models: () => Pr instantiationService.stub(IAgentConfigurationService, configurationService); instantiationService.stub(IAgentHostCustomizationEnablementService, createNoopCustomizationEnablementService()); instantiationService.stub(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); - instantiationService.stub(IAgentSdkDownloader, { - _serviceBrand: undefined, - isSdkResolvableWithoutDownload: () => new Promise(() => { }), - }); + instantiationService.stub(IAgentSdkDownloader, sdkDownloader); instantiationService.stub(IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE); instantiationService.stub(IAgentHostOTelService, { _serviceBrand: undefined, getNativeSdkTelemetryConfig: async () => undefined }); instantiationService.stub(IAgentHostSessionTitleSignal, { _serviceBrand: undefined, onDidChangeSessionTitle: Event.None }); instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); - instantiationService.stub(INativeEnvironmentService, { userHome: URI.file(userHome) }); + instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); instantiationService.stub(ILogService, logService); - return disposables.add(instantiationService.createInstance(CodexAgent)); + const agent = disposables.add(instantiationService.createInstance(CodexAgent)); + return { agent, stateManager, configurationService, sdkDownloader }; } -suite('CodexAgent model refresh', () => { +function createAgent(disposables: Pick, models: () => Promise, rootConfig: Record = {}, sdkDownloader = new RecordingAgentSdkDownloader()): CodexAgent { + return createAgentContext(disposables, models, rootConfig, sdkDownloader).agent; +} - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - const modelListResponse = { - data: [{ - id: 'gpt-5.6-sol', - model: 'gpt-5.6-sol', - upgrade: null, - upgradeInfo: null, - availabilityNux: null, - displayName: 'GPT-5.6-Sol', - description: 'Latest frontier agentic coding model.', - hidden: false, - supportedReasoningEfforts: [ - { reasoningEffort: 'low', description: 'Fast responses with lighter reasoning' }, - { reasoningEffort: 'medium', description: 'Balances speed and reasoning depth for everyday tasks' }, - { reasoningEffort: 'high', description: 'Greater reasoning depth for complex problems' }, - { reasoningEffort: 'xhigh', description: 'Extra high reasoning depth for complex problems' }, - { reasoningEffort: 'max', description: 'Maximum reasoning depth for the hardest problems' }, - { reasoningEffort: 'ultra', description: 'Maximum reasoning with automatic task delegation' }, - ], - defaultReasoningEffort: 'low', - inputModalities: ['text', 'image'], - supportsPersonality: true, - additionalSpeedTiers: [], - serviceTiers: [], - defaultServiceTier: null, - isDefault: true, - }], - nextCursor: null, +const modelListResponse = { + data: [{ + id: 'gpt-5.6-sol', + model: 'gpt-5.6-sol', + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: 'GPT-5.6-Sol', + description: 'Latest frontier agentic coding model.', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Fast responses with lighter reasoning' }, + { reasoningEffort: 'medium', description: 'Balances speed and reasoning depth for everyday tasks' }, + { reasoningEffort: 'high', description: 'Greater reasoning depth for complex problems' }, + { reasoningEffort: 'xhigh', description: 'Extra high reasoning depth for complex problems' }, + { reasoningEffort: 'max', description: 'Maximum reasoning depth for the hardest problems' }, + { reasoningEffort: 'ultra', description: 'Maximum reasoning with automatic task delegation' }, + ], + defaultReasoningEffort: 'low', + inputModalities: ['text', 'image'], + supportsPersonality: true, + additionalSpeedTiers: [], + serviceTiers: [], + defaultServiceTier: null, + isDefault: true, + }], + nextCursor: null, +}; + +/** + * @param requests records every method the agent asks for, so a test can assert + * on enumeration specifically — `config/read` shares this connection once the + * SDK is local, so a raw "did we connect" count conflates callers. + */ +function createChatGPTConnection(account: unknown = { type: 'chatgpt', email: 'person@example.com', planType: 'plus' }, requests: string[] = []) { + return { + kind: 'ready', + client: { + request: async (method: string) => { + requests.push(method); + if (method === 'account/read') { + return { account, requiresOpenaiAuth: true }; + } + if (method === 'config/read') { + return { config: { model_provider: 'openai' } }; + } + if (method === 'model/list') { + return modelListResponse; + } + throw new Error(`Unexpected request: ${method}`); + }, + }, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, }; +} - function createChatGPTHome(): string { - const userHome = fs.mkdtempSync(join(os.tmpdir(), 'vscode-codex-agent-test-')); - const codexHome = join(userHome, '.codex'); - fs.mkdirSync(codexHome); - fs.writeFileSync(join(codexHome, 'auth.json'), JSON.stringify({ - auth_mode: 'chatgpt', - tokens: { access_token: 'access', refresh_token: 'refresh' }, - })); - return userHome; - } +suite('CodexAgent model refresh', () => { - function createChatGPTConnection(account: unknown = { type: 'chatgpt', email: 'person@example.com', planType: 'plus' }) { - return { - kind: 'ready', - client: { - request: async (method: string) => { - if (method === 'account/read') { - return { account, requiresOpenaiAuth: true }; - } - if (method === 'config/read') { - return { config: { model_provider: 'openai' } }; - } - if (method === 'model/list') { - return modelListResponse; - } - throw new Error(`Unexpected request: ${method}`); - }, - }, - proxyHandle: { dispose() { } }, - child: { kill: () => true }, - }; - } + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('eagerly enumerates authoritative ChatGPT models when existing auth is detected', async () => { - const userHome = createChatGPTHome(); - try { - const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, userHome); - const connection = createChatGPTConnection(); - let resolveConnection!: () => void; - const connectionPromise = new Promise(resolve => { resolveConnection = () => resolve(connection as never); }); - let ensureConnectionCalls = 0; - agent['_isSdkResolvableWithoutDownload'] = async () => false; - agent['_ensureConnection'] = async () => { - ensureConnectionCalls++; - return connectionPromise; - }; + test('eagerly enumerates the authoritative catalog at startup when the SDK is already local', async () => { + const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + const requests: string[] = []; + let resolveConnection!: () => void; + const connectionPromise = new Promise(resolve => { resolveConnection = () => resolve(createChatGPTConnection(undefined, requests) as never); }); + let connectionRequested = false; + agent['_ensureConnection'] = async () => { + connectionRequested = true; + return connectionPromise; + }; - await new Promise(resolve => setTimeout(resolve, 0)); - assert.strictEqual(ensureConnectionCalls, 1); - assert.deepStrictEqual(agent.models.get(), []); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.deepStrictEqual({ connectionRequested, models: agent.models.get() }, { connectionRequested: true, models: [] }); - resolveConnection(); - await agent.refreshModels(); + resolveConnection(); + await agent.refreshModels(); - assert.deepStrictEqual(agent.models.get().map(model => ({ provider: model.provider, id: model.id, name: model.name, meta: model._meta })), [{ + assert.deepStrictEqual({ + // One enumeration, not one per caller that happened to want the connection. + enumerations: requests.filter(method => method === 'model/list').length, + models: agent.models.get().map(model => ({ provider: model.provider, id: model.id, name: model.name, meta: model._meta })), + }, { + enumerations: 1, + models: [{ provider: 'chatgpt', id: toCodexModelSelectionId('openai', 'gpt-5.6-sol'), name: 'GPT-5.6-Sol', meta: { modelSourceId: 'chatgptSubscription' }, - }]); - } finally { - fs.rmSync(userHome, { recursive: true, force: true }); - } + }], + }); }); - test('does not enumerate ChatGPT models while signed-out use is disabled', async () => { - const userHome = createChatGPTHome(); - try { - const agent = createAgent(disposables, async () => [], {}, userHome); - let ensureConnectionCalls = 0; - agent['_isSdkResolvableWithoutDownload'] = async () => false; - agent['_ensureConnection'] = async () => { - ensureConnectionCalls++; - return createChatGPTConnection() as never; - }; + test('does not enumerate at startup while signed-out use is disabled', async () => { + const agent = createAgent(disposables, async () => [], {}); + const requests: string[] = []; + agent['_ensureConnection'] = async () => createChatGPTConnection(undefined, requests) as never; - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 0)); - assert.strictEqual(ensureConnectionCalls, 0); - assert.deepStrictEqual(agent.models.get(), []); - } finally { - fs.rmSync(userHome, { recursive: true, force: true }); - } + // Reading `config.toml` may still open a connection — that is unrelated to + // enumeration. What must not happen is asking about the account or catalog. + assert.deepStrictEqual({ + enumerationRequests: requests.filter(method => method === 'account/read' || method === 'model/list'), + models: agent.models.get(), + }, { + enumerationRequests: [], + models: [], + }); }); - test('requires Copilot unless signed-out use and persisted ChatGPT auth are both present', () => { - const userHome = createChatGPTHome(); - try { - const copilotRequired = (agent: CodexAgent) => agent.getProtectedResources()[0].required; - assert.deepStrictEqual({ - noChatGPTAuth: copilotRequired(createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true })), - chatGPTAuthEnabled: copilotRequired(createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, userHome)), - chatGPTAuthDisabled: copilotRequired(createAgent(disposables, async () => [], {}, userHome)), - }, { - noChatGPTAuth: true, - chatGPTAuthEnabled: false, - chatGPTAuthDisabled: true, - }); - } finally { - fs.rmSync(userHome, { recursive: true, force: true }); - } + test('reports an empty catalog rather than downloading the SDK to enumerate', async () => { + const sdkDownloader = new RecordingAgentSdkDownloader(false); + const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, sdkDownloader); + let ensureConnectionCalls = 0; + agent['_isSdkResolvableWithoutDownload'] = async () => false; + agent['_ensureConnection'] = async () => { + ensureConnectionCalls++; + return createChatGPTConnection() as never; + }; + + await agent.refreshModels(); + + // The download is an explicit gesture now, so a refresh that finds no local + // SDK reports the honest empty catalog and leaves the offer to the banner. + assert.deepStrictEqual({ + ensureConnectionCalls, + models: agent.models.get(), + downloads: sdkDownloader.progressInterests, + }, { + ensureConnectionCalls: 0, + models: [], + downloads: [], + }); }); - test('requires Copilot again after persisted ChatGPT auth is removed', async () => { - const userHome = createChatGPTHome(); - try { - const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, userHome); - assert.strictEqual(agent.getProtectedResources()[0].required, false); - - fs.rmSync(join(userHome, '.codex', 'auth.json')); - agent['_connection'] = createChatGPTConnection(null) as never; - await agent.refreshModels(); - - assert.deepStrictEqual({ - copilotRequired: agent.getProtectedResources()[0].required, - models: agent.models.get(), - }, { - copilotRequired: true, - models: [], - }); - } finally { - fs.rmSync(userHome, { recursive: true, force: true }); - } + test('never requires Copilot, whatever the flag says and whatever the account turns out to be', async () => { + const copilotRequired = (agent: CodexAgent) => agent.getProtectedResources()[0].required; + const withoutSdk = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + withoutSdk['_isSdkResolvableWithoutDownload'] = async () => false; + const withoutAccount = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + withoutAccount['_connection'] = createChatGPTConnection(null) as never; + const withAccount = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + withAccount['_connection'] = createChatGPTConnection() as never; + await Promise.all([withoutAccount.refreshModels(), withAccount.refreshModels()]); + + // `required: false` is unconditional: a `true` here from any of these + // combinations puts the whole Agents window behind a GitHub sign-in wall, + // because `resolveSignedOutWindowGate` forces sign-in only when *every* + // session type requires GitHub. + assert.deepStrictEqual({ + signedOutUseDisabled: copilotRequired(createAgent(disposables, async () => [], {})), + noLocalSdk: copilotRequired(withoutSdk), + noAccount: copilotRequired(withoutAccount), + chatGPTAccount: copilotRequired(withAccount), + }, { + signedOutUseDisabled: false, + noLocalSdk: false, + noAccount: false, + chatGPTAccount: false, + }); }); test('waits for an app-server already starting when signed-out use becomes enabled', async () => { - const userHome = createChatGPTHome(); - try { - const agent = createAgent(disposables, async () => [], {}, userHome); - const connection = createChatGPTConnection(); - let resolveConnection!: () => void; - agent['_connection'] = { kind: 'starting', promise: new Promise(resolve => { resolveConnection = () => resolve(connection as never); }) }; - - agent['_configurationService'].updateRootConfig({ [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); - await new Promise(resolve => setTimeout(resolve, 0)); - assert.deepStrictEqual(agent.models.get(), []); + const agent = createAgent(disposables, async () => [], {}); + const connection = createChatGPTConnection(); + let resolveConnection!: () => void; + agent['_connection'] = { kind: 'starting', promise: new Promise(resolve => { resolveConnection = () => resolve(connection as never); }) }; - resolveConnection(); - await agent.refreshModels(); + agent['_configurationService'].updateRootConfig({ [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.deepStrictEqual(agent.models.get(), []); - assert.deepStrictEqual(agent.models.get().map(model => model.id), [toCodexModelSelectionId('openai', 'gpt-5.6-sol')]); - } finally { - fs.rmSync(userHome, { recursive: true, force: true }); - } + resolveConnection(); + await agent.refreshModels(); + + assert.deepStrictEqual(agent.models.get().map(model => model.id), [toCodexModelSelectionId('openai', 'gpt-5.6-sol')]); }); - test('does not publish ChatGPT models when detected credentials are invalid', async () => { - const userHome = createChatGPTHome(); - try { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', supported_endpoints: ['/responses'] }] as CCAModel[]; - const agent = createAgent(disposables, async () => copilotModels, { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, userHome); - agent['_githubToken'] = 'token'; - agent['_connection'] = createChatGPTConnection(null) as never; - - await agent.refreshModels(); - - assert.deepStrictEqual({ - providers: agent.models.get().map(model => model.provider), - copilotRequired: agent.getProtectedResources()[0].required, - }, { - providers: ['copilot'], - copilotRequired: true, - }); - } finally { - fs.rmSync(userHome, { recursive: true, force: true }); - } + test('publishes no ChatGPT models when the app server reports no account', async () => { + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', supported_endpoints: ['/responses'] }] as CCAModel[]; + const agent = createAgent(disposables, async () => copilotModels, { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + agent['_githubToken'] = 'token'; + agent['_connection'] = createChatGPTConnection(null) as never; + + await agent.refreshModels(); + + assert.deepStrictEqual({ + providers: agent.models.get().map(model => model.provider), + copilotRequired: agent.getProtectedResources()[0].required, + }, { + providers: ['copilot'], + copilotRequired: false, + }); }); test('does not publish a model when authoritative discovery fails', async () => { - const userHome = createChatGPTHome(); - try { - const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }, userHome); - agent['_connection'] = { - kind: 'ready', - client: { - request: async (method: string) => { - if (method === 'account/read') { - return { account: { type: 'chatgpt', email: null, planType: 'plus' }, requiresOpenaiAuth: true }; - } - throw new Error('model discovery failed'); - }, + const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + agent['_connection'] = { + kind: 'ready', + client: { + request: async (method: string) => { + if (method === 'account/read') { + return { account: { type: 'chatgpt', email: null, planType: 'plus' }, requiresOpenaiAuth: true }; + } + throw new Error('model discovery failed'); }, - proxyHandle: { dispose() { } }, - child: { kill: () => true }, - } as never; - - await agent.refreshModels(); - assert.deepStrictEqual(agent.models.get(), []); - } finally { - fs.rmSync(userHome, { recursive: true, force: true }); - } + }, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + + await agent.refreshModels(); + assert.deepStrictEqual(agent.models.get(), []); }); test('keeps the last known-good models when a periodic refresh fails', async () => { @@ -594,7 +599,9 @@ suite('CodexAgent model refresh', () => { await agent['_signOutOfChatGPT'](); assert.deepStrictEqual({ - requests, + // Scoped to the sign-out gesture: with the SDK local, the startup + // `config.toml` read lands on this same connection. + requests: requests.filter(method => method.startsWith('account/')), accountStatus: agent['_openAIAccountState'].status, }, { requests: ['account/logout', 'account/read'], @@ -659,3 +666,178 @@ suite('CodexAgent model refresh', () => { }); }); }); + +suite('CodexAgent — agent SDK setup channel', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + /** What the workbench would read off root state right now. */ + function readSetup(ctx: ITestAgentContext) { + return readAgentSdkSetupInfos(ctx.stateManager.rootState).find(setup => setup.agent === 'codex'); + } + + /** Addresses a download request at an agent the way `IAgentSdkSetupService` does. */ + function dispatchDownload(ctx: ITestAgentContext, agent = 'codex', request = 'req-1'): void { + ctx.configurationService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request } }); + } + + /** Waits for the ctor's queued publish (and any refresh it chains) to settle. */ + async function settle(): Promise { + for (let i = 0; i < 20; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + /** + * A build that knows where to fetch the SDK from but has not yet — the state + * the banner's offer exists for. Both flags are set explicitly because + * `isAvailable` false would fall through to `resolveCodexDevSdkRoot()`. + */ + function createNotDownloaded(): RecordingAgentSdkDownloader { + const sdkDownloader = new RecordingAgentSdkDownloader(); + sdkDownloader.resolvableWithoutDownload = false; + return sdkDownloader; + } + + test('an SDK already on disk publishes `ready`, plus the docs URL and sign-in affordance the banner offers', async () => { + const ctx = createAgentContext(disposables, async () => []); + await settle(); + + assert.deepStrictEqual(readSetup(ctx), { + agent: 'codex', + download: 'ready', + setupDocsUrl: 'https://learn.chatgpt.com/codex/auth', + // Unlike Claude, ChatGPT sign-in is a control request the app server + // answers, so the banner can start it without the user leaving the window. + signInProviderName: 'ChatGPT', + }); + }); + + test('a cold cache publishes `notDownloaded`, which is what turns the banner into an offer', async () => { + const ctx = createAgentContext(disposables, async () => [], {}, createNotDownloaded()); + await settle(); + + assert.strictEqual(readSetup(ctx)?.download, 'notDownloaded'); + }); + + test('an explicit download fetches the SDK, holds progress interest for the fetch, and ends at `ready`', async () => { + const sdkDownloader = createNotDownloaded(); + let releaseDownload = () => { }; + const downloaded = new Promise(resolve => { + // Releasing the gate is the moment the SDK lands on disk. + releaseDownload = () => { sdkDownloader.resolvableWithoutDownload = true; resolve(); }; + }); + sdkDownloader.loadSdkRootResult = async () => { await downloaded; return '/tmp/codex-sdk'; }; + const ctx = createAgentContext(disposables, async () => [], {}, sdkDownloader); + // The refresh the download chains must not spawn a real app server. + ctx.agent['_ensureConnection'] = async () => { throw new Error('offline'); }; + await settle(); + + dispatchDownload(ctx); + await settle(); + const inFlight = { + download: readSetup(ctx)?.download, + interests: [...sdkDownloader.progressInterests], + held: sdkDownloader.heldProgressInterests, + }; + + releaseDownload(); + await settle(); + + assert.deepStrictEqual({ inFlight, after: readSetup(ctx)?.download, held: sdkDownloader.heldProgressInterests }, { + inFlight: { download: 'downloading', interests: ['codex'], held: 1 }, + after: 'ready', + held: 0, + }); + }); + + test('a download that lands stays `downloading` until the catalog does, so the banner never flashes "no account"', async () => { + const sdkDownloader = createNotDownloaded(); + sdkDownloader.loadSdkRootResult = async () => { sdkDownloader.resolvableWithoutDownload = true; return '/tmp/codex-sdk'; }; + const ctx = createAgentContext(disposables, async () => [], {}, sdkDownloader); + let releaseEnumeration = () => { }; + const enumerated = new Promise(resolve => { releaseEnumeration = resolve; }); + const connection = createChatGPTConnection(); + ctx.agent['_ensureConnection'] = async () => ({ + ...connection, + client: { + request: async (method: string) => { + if (method === 'model/list') { + await enumerated; + } + return connection.client.request(method); + }, + }, + } as never); + await settle(); + + dispatchDownload(ctx); + await settle(); + const enumerating = { download: readSetup(ctx)?.download, models: ctx.agent.models.get().length }; + + releaseEnumeration(); + await settle(); + + assert.deepStrictEqual({ enumerating, after: readSetup(ctx)?.download, models: ctx.agent.models.get().length }, { + // `ready` while the catalog is still empty is precisely how the window + // renders "we looked and found no account". + enumerating: { download: 'downloading', models: 0 }, + after: 'ready', + models: 1, + }); + }); + + test('the request key is cleared as it is consumed, so an identical later press still lands', async () => { + const sdkDownloader = createNotDownloaded(); + let downloads = 0; + sdkDownloader.loadSdkRootResult = async () => { downloads++; return '/tmp/codex-sdk'; }; + const ctx = createAgentContext(disposables, async () => [], {}, sdkDownloader); + ctx.agent['_ensureConnection'] = async () => { throw new Error('offline'); }; + await settle(); + + dispatchDownload(ctx, 'codex', 'press-1'); + await settle(); + const consumed = ctx.configurationService.getRootConfigValues()[AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]; + + dispatchDownload(ctx, 'codex', 'press-2'); + await settle(); + + assert.deepStrictEqual({ consumed, downloads }, { consumed: undefined, downloads: 2 }); + }); + + test('a request addressed to another agent is ignored', async () => { + const sdkDownloader = createNotDownloaded(); + const ctx = createAgentContext(disposables, async () => [], {}, sdkDownloader); + await settle(); + + dispatchDownload(ctx, 'claude'); + await settle(); + + assert.deepStrictEqual({ + downloads: sdkDownloader.progressInterests, + // Left in place for the agent it names, rather than consumed by this one. + key: ctx.configurationService.getRootConfigValues()[AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY], + }, { + downloads: [], + key: { agent: 'claude', request: 'req-1' }, + }); + }); + + test('a failed download releases the progress interest and stops claiming to be downloading', async () => { + const sdkDownloader = createNotDownloaded(); + sdkDownloader.loadSdkRootResult = async () => { throw new Error('CDN unreachable'); }; + const ctx = createAgentContext(disposables, async () => [], {}, sdkDownloader); + await settle(); + + dispatchDownload(ctx); + await settle(); + + assert.deepStrictEqual({ + download: readSetup(ctx)?.download, + held: sdkDownloader.heldProgressInterests, + }, { + download: 'notDownloaded', + held: 0, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts index c4e6a27014573..ab59ebd65eadb 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts @@ -19,6 +19,7 @@ import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { IAgentConfigurationService } from '../../../node/agentConfigurationService.js'; import { IAgentHostCustomizationEnablementService } from '../../../node/agentHostCustomizationEnablementService.js'; import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js'; +import { RecordingAgentSdkDownloader } from '../testAgentSdkDownloader.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; @@ -40,7 +41,7 @@ function createAgent(disposables: Pick): CodexAgent { getRootValue: () => undefined, }); instantiationService.stub(IAgentHostCustomizationEnablementService, createNoopCustomizationEnablementService()); - instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined }); + instantiationService.stub(IAgentSdkDownloader, new RecordingAgentSdkDownloader()); instantiationService.stub(IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE); instantiationService.stub(IAgentHostOTelService, { _serviceBrand: undefined, getNativeSdkTelemetryConfig: async () => undefined }); instantiationService.stub(IAgentHostSessionTitleSignal, { _serviceBrand: undefined, onDidChangeSessionTitle: Event.None }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts index 88b2996b9ad51..8e6ddfc1a4b3e 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts @@ -23,6 +23,7 @@ import { IAgentHostCustomizationEnablementService } from '../../../node/agentHos import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../../../node/agentHostSessionTitleSignal.js'; import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js'; +import { RecordingAgentSdkDownloader } from '../testAgentSdkDownloader.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; @@ -71,7 +72,7 @@ function createTestContext(disposables: Pick): { stateMa getRootValue: () => undefined, }); instantiationService.stub(IAgentHostCustomizationEnablementService, createNoopCustomizationEnablementService()); - instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined }); + instantiationService.stub(IAgentSdkDownloader, new RecordingAgentSdkDownloader()); instantiationService.stub(IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE); instantiationService.stub(IAgentHostOTelService, otelService); instantiationService.stub(IAgentHostSessionTitleSignal, disposables.add(new AgentHostSessionTitleSignal(stateManager))); diff --git a/src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts b/src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts new file mode 100644 index 0000000000000..ecb6b3ff81c24 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IAgentSdkDownloader, IAgentSdkPackage } from '../../node/agentSdkDownloader.js'; + +/** + * Downloader stub that records the interactions worth asserting on and refuses + * the rest loudly. + * + * {@link available} answers `isAvailable` — "this build knows where to fetch the + * SDK from". {@link resolvableWithoutDownload} answers the separate question of + * whether it is already on disk, and defaults to {@link available} because that + * is the common "SDK is here" case. Setting `available` true and + * `resolvableWithoutDownload` false is the state the setup banner exists for: a + * download is possible but has not happened yet. Neither ever falls through to + * an agent's dev fallback, which would read this repo's `node_modules` and make + * the answer depend on the machine. + * + * {@link loadSdkRootResult} is unset by default, so an unexpected cold download + * surfaces as a thrown error rather than a silently mocked success. Fetching is + * the downloader's own job and is covered by its own tests; what agents owe is + * the progress interest held for the duration of a user-requested download, + * which is what {@link heldProgressInterests} pins. + */ +export class RecordingAgentSdkDownloader implements IAgentSdkDownloader { + declare readonly _serviceBrand: undefined; + + readonly onDidDownloadProgress = Event.None; + + /** Package ids for progress interests taken, and how many are still held. */ + readonly progressInterests: string[] = []; + heldProgressInterests = 0; + + /** Whether the SDK is already on disk. Defaults to {@link available}. */ + resolvableWithoutDownload: boolean | undefined; + + /** What `loadSdkRoot` resolves to. Unset means "no download was expected here". */ + loadSdkRootResult: (() => Promise) | undefined; + + constructor(public available = true) { } + + acquireDownloadProgressInterest(pkg: IAgentSdkPackage): IDisposable { + this.progressInterests.push(pkg.id); + this.heldProgressInterests++; + return toDisposable(() => { this.heldProgressInterests--; }); + } + + isAvailable(): boolean { + return this.available; + } + + async isSdkResolvableWithoutDownload(): Promise { + return this.resolvableWithoutDownload ?? this.available; + } + + loadSdkRoot(pkg: IAgentSdkPackage): Promise { + if (!this.loadSdkRootResult) { + throw new Error(`test stub: unexpected SDK download for ${pkg.id}`); + } + return this.loadSdkRootResult(); + } +} diff --git a/src/vs/sessions/browser/sessionsAuthGate.ts b/src/vs/sessions/browser/sessionsAuthGate.ts index 1745a46bb8b75..ca553b8d88f38 100644 --- a/src/vs/sessions/browser/sessionsAuthGate.ts +++ b/src/vs/sessions/browser/sessionsAuthGate.ts @@ -114,38 +114,3 @@ export function observeAllowSignedOutWhenUsable(configurationService: IConfigura Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(AgentHostAllowSignedOutWhenUsableSettingId)), () => isAllowSignedOutWhenUsableEnabled(configurationService)); } - -/** - * Inputs to the "discovered your existing configuration" nudge for a - * single agent-host session type. - */ -export interface IDiscoveredConfigNudgeContext { - /** Whether a GitHub account is currently signed in. */ - readonly signedIn: boolean; - /** The `chat.agentHost.allowSignedOutWhenUsable` experimentation opt-in. */ - readonly allowSignedOutWhenUsable: boolean; - /** - * Whether the agent's session type is usable without GitHub right now — i.e. - * its agent discovered an existing native configuration and is running in - * native mode rather than the Copilot proxy. - */ - readonly usableWithoutGitHub: boolean; - /** - * Whether the user has already dismissed this nudge, which silences it for - * good. Once muted, the nudge never shows again regardless of the other - * inputs. - */ - readonly muted: boolean; -} - -/** - * Decides whether to surface the discovered-config nudge for one agent-host - * session type: shown only to a signed-out user who has opted in, when that - * type is usable without GitHub right now — the agent found an existing native - * config, so we let them in and explain how to switch to a Copilot subscription - * instead. Signed-in users never see it; with the opt-in off, or once the user - * has muted it, it is always false. - */ -export function shouldShowDiscoveredConfigNudge(context: IDiscoveredConfigNudgeContext): boolean { - return !context.signedIn && context.allowSignedOutWhenUsable && context.usableWithoutGitHub && !context.muted; -} diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index 06c198ef04125..c0815437da19a 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts @@ -12,6 +12,7 @@ import { IChatSessionsService } from '../../../../../workbench/contrib/chat/comm import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { getSessionTypeAvailability, getSessionTypeUnavailableLabel, SessionTypeAvailability } from '../../../../../workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { IChatInputNotificationService } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISession } from '../../../../services/sessions/common/session.js'; @@ -47,10 +48,11 @@ export class MobileSessionTypePicker extends SessionTypePicker { @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @ILanguageModelsService languageModelsService: ILanguageModelsService, @IConfigurationService configurationService: IConfigurationService, + @IChatInputNotificationService chatInputNotificationService: IChatInputNotificationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, ) { - super(session, options, actionWidgetService, sessionsManagementService, _sessionsProvidersService, storageService, telemetryService, chatSessionsService, chatEntitlementService, languageModelsService, configurationService, contextKeyService); + super(session, options, actionWidgetService, sessionsManagementService, _sessionsProvidersService, storageService, telemetryService, chatSessionsService, chatEntitlementService, languageModelsService, configurationService, chatInputNotificationService, contextKeyService); } override render(container: HTMLElement, options?: { className?: string }): void { diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 99023ab0df3cd..76f8116e367b1 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -26,6 +26,8 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { getSessionTypeAvailability, getSessionTypePickerAvailability, getSessionTypeUnavailableDescription, getSessionTypeUnavailableHover, SessionTypeAvailability } from '../../../../workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.js'; +import { hasAgentSdkSetupNotification } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; +import { IChatInputNotificationService } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; @@ -167,6 +169,7 @@ export class SessionTypePicker extends Disposable { @IChatEntitlementService protected readonly chatEntitlementService: IChatEntitlementService, @ILanguageModelsService protected readonly languageModelsService: ILanguageModelsService, @IConfigurationService protected readonly configurationService: IConfigurationService, + @IChatInputNotificationService protected readonly chatInputNotificationService: IChatInputNotificationService, @IContextKeyService contextKeyService: IContextKeyService, ) { super(); @@ -462,6 +465,7 @@ export class SessionTypePicker extends Disposable { modelTarget, getSessionTypeAvailability(this.chatSessionsService, this.chatEntitlementService, this.languageModelsService, modelTarget, allowSignedOutWhenUsable), allowSignedOutWhenUsable, + hasAgentSdkSetupNotification(this.chatInputNotificationService, modelTarget), ); const unavailable = availability !== SessionTypeAvailability.Available; const item: ISessionTypePickerItem = { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts index 633c3026b64bb..1355c07f504b7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -12,6 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IChatInputNotificationService } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; @@ -137,6 +138,7 @@ function createPicker( lookupLanguageModel: () => undefined, }); instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(IChatInputNotificationService, { getActiveNotification: () => undefined }); instantiationService.stub(IContextKeyService, new MockContextKeyService()); return disposables.add(instantiationService.createInstance(TestSessionTypePicker, session, options)); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiscoveredConfigNotification.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiscoveredConfigNotification.ts deleted file mode 100644 index 7466e89dfef45..0000000000000 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiscoveredConfigNotification.ts +++ /dev/null @@ -1,162 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../../../base/common/event.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { localize } from '../../../../../nls.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { AgentHostAllowSignedOutWhenUsableSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { IWorkbenchContribution } from '../../../../../workbench/common/contributions.js'; -import { SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; -import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotificationService } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; -import { ConditionalAuthState, conditionalAuthState, isAllowSignedOutWhenUsableEnabled, shouldShowDiscoveredConfigNudge } from '../../../../browser/sessionsAuthGate.js'; - -const DISCOVERED_CONFIG_NOTIFICATION_ID = 'agentHost.discoveredConfig.claude'; - -/** Single entry point for starting GitHub Copilot sign-in from a nudge. */ -const SIGN_IN_COMMAND_ID = 'workbench.action.chat.triggerSetup'; - -/** - * Persists the user's dismissal. The discovered config lives on this machine, so - * the preference is scoped to the machine — {@link StorageScope.APPLICATION} to - * span profiles and workspaces, and {@link StorageTarget.MACHINE} so settings - * sync does not carry it to a machine where no such config exists. - */ -const MUTED_STORAGE_KEY = 'agentHost.discoveredConfig.claude.muted'; - -/** - * Surfaces a calm chat-input notification in the Agents window when a signed-out - * user — who has opted into `chat.agentHost.allowSignedOutWhenUsable` — lands - * with the Claude agent running in native mode because it discovered an existing - * configuration on disk. Instead of forcing GitHub sign-in, the Agents window - * lets them in; this banner explains what happened and offers a single "Sign in - * to GitHub" action for anyone who actually meant to use a Copilot subscription. - * - * The banner is scoped to the Claude session type (so it only renders when that - * harness is selected) and clears itself the moment the user signs in or the - * agent stops advertising native mode. Dismissing it with the X persists a - * machine-wide choice not to show it again — the nudge is informational, so a - * user who has read it once has read it for good. Sending a message merely hides - * it for the current window. - */ -export class AgentHostDiscoveredConfigNotificationContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'sessions.contrib.agentHostDiscoveredConfigNotification'; - - private _shown = false; - /** - * Set once the initial default-account resolution has completed. Until then - * {@link IDefaultAccountService.currentDefaultAccount} reads as `null` even for - * a signed-in user, so the nudge stays suppressed to avoid flashing at a - * signed-in user during the startup gap. - */ - private _accountResolved = false; - - constructor( - @IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService, - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, - @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IStorageService private readonly _storageService: IStorageService, - ) { - super(); - - // Dismissing the banner is the user telling us they've read it, so persist - // that; the storage listener below then re-drives `_update` to tear it - // down. `onDidDismiss` fires only for an explicit dismissal — the - // auto-dismiss on send does not, so sending a message still just hides - // the nudge for this window. - this._register(this._chatInputNotificationService.onDidDismiss(id => { - if (id === DISCOVERED_CONFIG_NOTIFICATION_ID) { - this._storageService.store(MUTED_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - })); - - // Signing in/out flips the nudge; a session-type change is how the agent - // host signals that Claude switched between native and proxy (i.e. whether - // it is usable without GitHub); the opt-in and the mute can both toggle at - // runtime (the mute from another window on this machine). - this._register(Event.any( - this._defaultAccountService.onDidChangeDefaultAccount, - this._sessionsManagementService.onDidChangeSessionTypes, - Event.filter(this._configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(AgentHostAllowSignedOutWhenUsableSettingId), this._store), - this._storageService.onDidChangeValue(StorageScope.APPLICATION, MUTED_STORAGE_KEY, this._store), - )(() => this._update())); - - // Until the account resolves, `currentDefaultAccount === null` reads as - // "signed out" and would flash this signed-out nudge at a signed-in user - // during startup. The account loads silently (no change event fires), so - // await the first resolution, then re-evaluate. - this._defaultAccountService.getDefaultAccount().then(() => { - if (this._store.isDisposed) { - return; - } - this._accountResolved = true; - this._update(); - }); - } - - private _update(): void { - // While the account is unresolved, `currentDefaultAccount` is null for - // everyone; treating that as "signed out" flashes the nudge at a signed-in - // user. Nothing is shown yet, so there is nothing to tear down — just wait. - const authState = conditionalAuthState(this._accountResolved, this._defaultAccountService.currentDefaultAccount !== null); - if (authState === ConditionalAuthState.Unresolved) { - return; - } - - // The Claude agent-host session type, once the host has advertised it. - // Two providers (local / remote agent host) can offer the same id, so - // prefer a usable instance and fall back to any for the display label. - const claudeTypes = this._sessionsManagementService.getAllProviderSessionTypes() - .filter(type => (type.sessionType.chatSessionType ?? type.sessionType.id) === SessionType.AgentHostClaude) - .map(type => type.sessionType); - const claude = claudeTypes.find(type => type.authRequirement === SessionTypeAuthRequirement.None) ?? claudeTypes[0]; - - const show = shouldShowDiscoveredConfigNudge({ - signedIn: authState === ConditionalAuthState.SignedIn, - allowSignedOutWhenUsable: isAllowSignedOutWhenUsableEnabled(this._configurationService), - usableWithoutGitHub: claude?.authRequirement === SessionTypeAuthRequirement.None, - muted: this._storageService.getBoolean(MUTED_STORAGE_KEY, StorageScope.APPLICATION, false), - }); - - if (!show) { - if (this._shown) { - this._chatInputNotificationService.deleteNotification(DISCOVERED_CONFIG_NOTIFICATION_ID); - this._shown = false; - } - return; - } - - // Already up: don't re-push, which would clear a pending user dismissal. - if (this._shown || !claude) { - return; - } - this._shown = true; - - this._chatInputNotificationService.setNotification({ - id: DISCOVERED_CONFIG_NOTIFICATION_ID, - severity: ChatInputNotificationSeverity.Info, - message: localize('agentHost.discoveredConfig.message', "We've discovered your existing {0} configuration.", claude.label), - description: localize('agentHost.discoveredConfig.description', "If you intended to use a Copilot subscription, sign in to GitHub."), - actions: [{ - kind: ChatInputNotificationActionKind.Command, - label: localize('agentHost.discoveredConfig.signIn', "Sign in to GitHub"), - commandId: SIGN_IN_COMMAND_ID, - // Dismissal is permanent now, so a sign-in click — which the user - // may still cancel — must not route through it. The banner retires - // on its own once the account resolves to signed in. - keepOpen: true, - }], - dismissible: true, - autoDismissOnMessage: true, - sessionTypes: [SessionType.AgentHostClaude], - }); - } -} 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 6d7906174b714..65448ea723cc1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts @@ -11,7 +11,7 @@ import { AgentHostContribution } from '../../../../../workbench/contrib/chat/bro import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { AgentHostTerminalContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { AgentHostAllowSignedOutWhenUsableContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAllowSignedOutWhenUsableContribution.js'; -import { AgentHostDiscoveredConfigNotificationContribution } from './agentHostDiscoveredConfigNotification.js'; +import { AgentHostSdkSetupNotificationContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; import { AgentHostSignedOutModelsNotificationContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSignedOutModelsNotification.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; @@ -89,6 +89,6 @@ class LocalAgentHostContribution extends Disposable implements IWorkbenchContrib registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostAllowSignedOutWhenUsableContribution.ID, AgentHostAllowSignedOutWhenUsableContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(AgentHostDiscoveredConfigNotificationContribution.ID, AgentHostDiscoveredConfigNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSignedOutModelsNotificationContribution.ID, AgentHostSignedOutModelsNotificationContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostSdkSetupNotificationContribution.ID, AgentHostSdkSetupNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(LocalAgentHostContribution.ID, LocalAgentHostContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostDiscoveredConfigNotification.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostDiscoveredConfigNotification.test.ts deleted file mode 100644 index 52dede3c4f86d..0000000000000 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostDiscoveredConfigNotification.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { timeout } from '../../../../../../base/common/async.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { isWeb } from '../../../../../../base/common/platform.js'; -import { mock } from '../../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentHostAllowSignedOutWhenUsableSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; -import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; -import { IChatInputNotification, IChatInputNotificationService } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; -import { SessionType } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { SessionTypeAuthRequirement } from '../../../../../services/sessions/common/session.js'; -import { IProviderSessionType, ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js'; -import { AgentHostDiscoveredConfigNotificationContribution } from '../../browser/agentHostDiscoveredConfigNotification.js'; - -class TestChatInputNotificationService extends Disposable implements IChatInputNotificationService { - declare readonly _serviceBrand: undefined; - - readonly onDidChange = Event.None; - private readonly _onDidDismiss = this._register(new Emitter()); - readonly onDidDismiss = this._onDidDismiss.event; - - readonly notifications = new Map(); - - setNotification(notification: IChatInputNotification): void { - this.notifications.set(notification.id, notification); - } - deleteNotification(id: string): void { - this.notifications.delete(id); - } - /** Mirrors the real service: a dismissal is remembered, not forgotten. */ - dismissNotification(id: string): void { - if (this.notifications.has(id)) { - this._onDidDismiss.fire(id); - } - } - getActiveNotification(): IChatInputNotification | undefined { - return [...this.notifications.values()].at(0); - } - handleMessageSent(): void { } - announceRendered(): void { } -} - -/** - * A signed-out user who has opted in, with Claude advertising that it runs on the - * user's own credentials — the one situation the nudge is written for. - */ -function createContribution(store: Pick, storageService = store.add(new InMemoryStorageService())) { - const notificationService = store.add(new TestChatInputNotificationService()); - const claude: IProviderSessionType = { - providerId: 'local-agent-host', - sessionType: { - id: 'claude', - label: 'Claude Code', - icon: Codicon.copilot, - chatSessionType: SessionType.AgentHostClaude, - authRequirement: SessionTypeAuthRequirement.None, - }, - }; - - store.add(new AgentHostDiscoveredConfigNotificationContribution( - notificationService, - new class extends mock() { - override readonly onDidChangeSessionTypes = Event.None; - override getAllProviderSessionTypes(): IProviderSessionType[] { return [claude]; } - }(), - new class extends mock() { - override readonly onDidChangeDefaultAccount = Event.None; - override readonly currentDefaultAccount = null; - override getDefaultAccount() { return Promise.resolve(null); } - }(), - new TestConfigurationService({ [AgentHostAllowSignedOutWhenUsableSettingId]: true }), - storageService, - )); - - return { notificationService, storageService }; -} - -suite('AgentHostDiscoveredConfigNotification', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - (isWeb ? test.skip : test)('nudges the signed-out user, with dismissal as the only off switch', async () => { - const { notificationService } = createContribution(store); - - // The account resolves asynchronously; the nudge waits for it. - await timeout(0); - - assert.deepStrictEqual([...notificationService.notifications.values()].map(notification => ({ - message: notification.message, - actions: notification.actions.map(action => ({ label: action.label, keepOpen: action.keepOpen })), - dismissible: notification.dismissible, - mute: notification.mute, - sessionTypes: notification.sessionTypes, - })), [{ - message: 'We\'ve discovered your existing Claude Code configuration.', - // `keepOpen` so a sign-in the user then cancels doesn't silence the nudge. - actions: [{ label: 'Sign in to GitHub', keepOpen: true }], - dismissible: true, - mute: undefined, - sessionTypes: [SessionType.AgentHostClaude], - }]); - }); - - (isWeb ? test.skip : test)('dismissing it silences the nudge on this machine for good', async () => { - const storageService = store.add(new InMemoryStorageService()); - const first = createContribution(store, storageService); - await timeout(0); - const notification = first.notificationService.getActiveNotification(); - - first.notificationService.dismissNotification(notification!.id); - - // A fresh contribution stands in for the next window on this machine. - const next = createContribution(store, storageService); - await timeout(0); - - assert.deepStrictEqual({ - afterDismissal: first.notificationService.notifications.size, - nextWindow: next.notificationService.notifications.size, - }, { - afterDismissal: 0, - nextWindow: 0, - }); - }); - - (isWeb ? test : test.skip)('does not nudge on web when signed-out operation is configured', async () => { - const { notificationService } = createContribution(store); - - await timeout(0); - - assert.strictEqual(notificationService.notifications.size, 0); - }); -}); diff --git a/src/vs/sessions/test/browser/sessionsAuthGate.test.ts b/src/vs/sessions/test/browser/sessionsAuthGate.test.ts index c53568bd9c038..42e554457d272 100644 --- a/src/vs/sessions/test/browser/sessionsAuthGate.test.ts +++ b/src/vs/sessions/test/browser/sessionsAuthGate.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; -import { ConditionalAuthState, conditionalAuthState, resolveSignedOutWindowGate, shouldShowDiscoveredConfigNudge, shouldShowGitHubWorkspaceGroupSignIn, SignedOutWindowGate } from '../../browser/sessionsAuthGate.js'; +import { ConditionalAuthState, conditionalAuthState, resolveSignedOutWindowGate, shouldShowGitHubWorkspaceGroupSignIn, SignedOutWindowGate } from '../../browser/sessionsAuthGate.js'; import { SessionTypeAuthRequirement } from '../../services/sessions/common/session.js'; suite('Sessions - Auth Gate', () => { @@ -57,48 +57,4 @@ suite('Sessions - Auth Gate', () => { ConditionalAuthState.SignedIn, ]); }); - - test('shows the discovered-config nudge only when signed out, opted in, the type is usable without GitHub, and not muted', () => { - // Independent source of truth: the nudge is the calm inverse of the gate — - // it appears iff the user is signed out AND the opt-in is on AND that type - // is usable without GitHub AND the user has not muted it. Of all 16 input - // combinations only one satisfies every condition. - const cases = [ - { signedIn: true, allowSignedOutWhenUsable: false, usableWithoutGitHub: false, muted: false }, - { signedIn: true, allowSignedOutWhenUsable: false, usableWithoutGitHub: false, muted: true }, - { signedIn: true, allowSignedOutWhenUsable: false, usableWithoutGitHub: true, muted: false }, - { signedIn: true, allowSignedOutWhenUsable: false, usableWithoutGitHub: true, muted: true }, - { signedIn: true, allowSignedOutWhenUsable: true, usableWithoutGitHub: false, muted: false }, - { signedIn: true, allowSignedOutWhenUsable: true, usableWithoutGitHub: false, muted: true }, - { signedIn: true, allowSignedOutWhenUsable: true, usableWithoutGitHub: true, muted: false }, - { signedIn: true, allowSignedOutWhenUsable: true, usableWithoutGitHub: true, muted: true }, - { signedIn: false, allowSignedOutWhenUsable: false, usableWithoutGitHub: false, muted: false }, - { signedIn: false, allowSignedOutWhenUsable: false, usableWithoutGitHub: false, muted: true }, - { signedIn: false, allowSignedOutWhenUsable: false, usableWithoutGitHub: true, muted: false }, - { signedIn: false, allowSignedOutWhenUsable: false, usableWithoutGitHub: true, muted: true }, - { signedIn: false, allowSignedOutWhenUsable: true, usableWithoutGitHub: false, muted: false }, - { signedIn: false, allowSignedOutWhenUsable: true, usableWithoutGitHub: false, muted: true }, - { signedIn: false, allowSignedOutWhenUsable: true, usableWithoutGitHub: true, muted: false }, - { signedIn: false, allowSignedOutWhenUsable: true, usableWithoutGitHub: true, muted: true }, - ]; - - assert.deepStrictEqual(cases.map(shouldShowDiscoveredConfigNudge), [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - ]); - }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts index a7816e4d81e08..50c6520f3e078 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts @@ -23,6 +23,7 @@ import { AgentHostContribution } from './agentHostChatContribution.js'; import { AgentHostCopilotCliSettingsContribution } from './agentHostCopilotCliSettingsContribution.js'; import { AgentHostOpenSessionLinkOpenerContribution } from './openSessionLinkOpener.contribution.js'; import { AgentHostSessionListContribution } from './agentHostSessionListContribution.js'; +import { AgentHostSdkSetupNotificationContribution } from './agentHostSdkSetupNotification.js'; import { AgentHostSignedOutModelsNotificationContribution } from './agentHostSignedOutModelsNotification.js'; import { AgentHostTerminalContribution } from './agentHostTerminalContribution.js'; import { CopilotConfigSlashSubmitHandlerContribution } from './copilotConfigSlashSubmitHandler.js'; @@ -37,5 +38,6 @@ registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTermin registerWorkbenchContribution2(AgentHostCopilotCliSettingsContribution.ID, AgentHostCopilotCliSettingsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostAllowSignedOutWhenUsableContribution.ID, AgentHostAllowSignedOutWhenUsableContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSignedOutModelsNotificationContribution.ID, AgentHostSignedOutModelsNotificationContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostSdkSetupNotificationContribution.ID, AgentHostSdkSetupNotificationContribution, WorkbenchPhase.AfterRestored); registerSingleton(IAgentHostByokLmHandler, AgentHostByokLmHandler, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts new file mode 100644 index 0000000000000..b7115292dcd62 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts @@ -0,0 +1,342 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { localize } from '../../../../../../nls.js'; +import { AgentHostAllowSignedOutWhenUsableSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import type { AgentSdkDownloadStatus, IAgentSdkSetupInfo } from '../../../../../../platform/agentHost/common/agentSdkSetup.js'; +import type { RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../../common/contributions.js'; +import { IAgentSdkSetupService, type AgentSdkSetupState } from '../../../../../services/agentHost/browser/agentSdkSetupService.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { hasAnyModelTargetingSessionType } from '../sessionTypeAvailability.js'; +import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationAction, IChatInputNotificationService, isChatInputNotificationApplicableToSessionType } from '../../widget/input/chatInputNotificationService.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; + +// #region State + +/** Everything one agent's {@link AgentSdkSetupState} is decided from. */ +export interface IAgentSdkSetupStateInputs { + /** The experimentation flag this whole feature stays behind. */ + readonly allowSignedOutWhenUsable: boolean; + /** Whether the user is signed in to GitHub (Copilot models already work). */ + readonly signedIn: boolean; + /** Whether entitlement has settled; before that "signed out" is not yet a fact. */ + readonly entitlementResolved: boolean; + readonly download: AgentSdkDownloadStatus; + /** Whether a fetch has been asked for and the host has not answered yet. */ + readonly downloadRequested: boolean; + /** Whether this agent has published any model — its own report of "I found an account". */ + readonly hasModels: boolean; +} + +/** + * The whole decision, as one pure function: what the banner renders and what the + * funnel records are two readings of this one state. A signed-in user already + * has Copilot models, so there is nothing to offer and BYOK stays undiscoverable + * for them (a deliberate v1 cut). + */ +export function getAgentSdkSetupState(inputs: IAgentSdkSetupStateInputs): AgentSdkSetupState | undefined { + if (!inputs.allowSignedOutWhenUsable || !inputs.entitlementResolved || inputs.signedIn) { + return undefined; + } + // Ahead of the download status because models are the honest end state: an + // agent that can enumerate a catalog has an account, whatever a status claims. + if (inputs.hasModels) { + return 'resolved'; + } + switch (inputs.download) { + // A fetch in flight has nothing to ask for — the host drives its own + // progress notification while it runs. + case 'downloading': return undefined; + // A request we sent covers the gap before the host answers it, so standing + // consent (or a click) never flashes the offer it has already satisfied. + case 'notDownloaded': return inputs.downloadRequested ? undefined : 'downloadOffered'; + case 'ready': return 'noAccount'; + } +} + +/** + * The state worth reporting to the funnel, or `undefined` when it adds + * nothing to what was last reported for this agent — `_update()` re-runs on every + * model, entitlement and root-state change. Comparing against the last *reported* + * state also counts each step once per user: a download that fails back to the + * offer is the same person still being asked. + */ +export function getAgentSdkSetupStateToReport(previous: AgentSdkSetupState | undefined, state: AgentSdkSetupState | undefined): AgentSdkSetupState | undefined { + // Reaching `resolved` without ever being asked for anything is a user who was + // set up before this feature saw them, not one it converted. + if (state === undefined || state === previous || (state === 'resolved' && previous === undefined)) { + return undefined; + } + return state; +} + +// #endregion + +// #region Banner + +/** + * The "no account" second line: one whole sentence per combination of routes, + * never assembled from localized fragments, because clause order is not stable + * across languages. The GitHub clause is unconditional — every agent behind this + * banner reaches models through our Copilot proxy once signed in, which is + * workbench knowledge rather than something an agent could declare. + */ +function noAccountDescription(setup: IAgentSdkSetupInfo, displayName: string): string { + const provider = setup.signInProviderName; + if (provider && setup.setupDocsUrl) { + return localize('agentHost.sdkSetup.noAccountDescription.all', "Sign in to GitHub to use GitHub Copilot models, sign in to {0} to use your {0} subscription, or read the instructions for other ways to set up {1}.", provider, displayName); + } + if (provider) { + return localize('agentHost.sdkSetup.noAccountDescription.signIn', "Sign in to GitHub to use GitHub Copilot models, or sign in to {0} to use your {0} subscription.", provider); + } + if (setup.setupDocsUrl) { + return localize('agentHost.sdkSetup.noAccountDescription.docs', "Sign in to GitHub to use GitHub Copilot models, or read the instructions for other ways to set up {0}.", displayName); + } + return localize('agentHost.sdkSetup.noAccountDescription', "Sign in to GitHub to use GitHub Copilot models."); +} + +/** + * The session type an agent's sessions run under, derived the same way + * `AgentHostChatContribution` derives it — so agent #3 needs no edit here. + * Scoped to the window's ambient host, which is itself the remote in a remote + * window; the Sessions app's additional `remote--` + * connections are outside this banner, as they are the Copilot one. + */ +export function agentSdkSetupSessionType(agent: string): string { + return `${LOCAL_AGENT_HOST_SCHEME_PREFIX}${agent}`; +} + +/** + * Each agent's own display name, keyed by provider id. Taken from root state + * rather than the setup channel: the host describes every agent there already, + * and a second wire source for one string would be free to disagree. Templating + * is also what keeps user-facing text out of the host — what crosses the wire is + * a proper noun the workbench cannot invent. + */ +export function getAgentDisplayNames(state: RootState | Error | undefined): ReadonlyMap { + const names = new Map(); + if (!state || state instanceof Error) { + return names; + } + for (const agent of state.agents ?? []) { + if (agent.displayName) { + names.set(agent.provider, agent.displayName); + } + } + return names; +} + +const AGENT_SDK_SETUP_NOTIFICATION_ID_PREFIX = 'agentHost.sdkSetup.'; + +export function agentSdkSetupNotificationId(agent: string): string { + return `${AGENT_SDK_SETUP_NOTIFICATION_ID_PREFIX}${agent}`; +} + +/** + * Whether a setup banner is currently being offered for the given session type. + * + * The pickers ask because the banner lives *inside* a session of the type it is + * scoped to: a harness with no models yet is greyed out by the ordinary + * availability rule, hiding the one thing telling the user how to fix that. + * Matching the setup id specifically matters — an unscoped notification (a quota + * warning, say) applies to every type and would un-grey all of them. + */ +export function hasAgentSdkSetupNotification(chatInputNotificationService: IChatInputNotificationService, sessionType: string): boolean { + return chatInputNotificationService.getActiveNotification(notification => + notification.id.startsWith(AGENT_SDK_SETUP_NOTIFICATION_ID_PREFIX) + && isChatInputNotificationApplicableToSessionType(notification, sessionType) + ) !== undefined; +} + +/** + * Render one agent's banner, or `undefined` when it has nothing to say. + * + * Every string is a template this layer owns, filled with the proper nouns the + * agent declared (`displayName`, `signInProviderName`) and varied by the routes + * it offers — nothing a person reads crosses the wire. The download lines + * never tie the SDK to an account: it is the same SDK behind the Copilot proxy, + * a subscription or a BYO key. + */ +export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displayName: string, state: AgentSdkSetupState | undefined): IChatInputNotification | undefined { + // Nothing to ask of a user who is already set up. An empty `displayName` means + // the host has not described this agent yet, and "Download the Agent" is worse + // than none; the next root-state change is moments away. + if (!displayName || state === undefined || state === 'resolved') { + return undefined; + } + const base = { + id: agentSdkSetupNotificationId(setup.agent), + severity: ChatInputNotificationSeverity.Info, + dismissible: false, + autoDismissOnMessage: false, + sessionTypes: [agentSdkSetupSessionType(setup.agent)], + } as const; + const action = (label: string, commandId: string): IChatInputNotificationAction => ({ + kind: ChatInputNotificationActionKind.Command, + label, + commandId, + commandArgs: [setup.agent], + keepOpen: true, + }); + if (state === 'downloadOffered') { + return { + ...base, + message: localize('agentHost.sdkSetup.download', "Download the {0} Agent", displayName), + description: localize('agentHost.sdkSetup.downloadDescription', "To use the {0} Agent, we need to download the {0} Agent SDK.", displayName), + actions: [action(localize('agentHost.sdkSetup.downloadAction', "Download"), AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID)], + }; + } + const actions: IChatInputNotificationAction[] = []; + if (setup.setupDocsUrl) { + actions.push(action(localize('agentHost.sdkSetup.docsAction', "Setup Instructions"), AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID)); + } + if (setup.signInProviderName) { + actions.push(action(localize('agentHost.sdkSetup.signInAction', "Sign in to {0}", setup.signInProviderName), AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID)); + } + // Last, because the widget styles the final action as the primary button and + // this is the route that works whatever the user has set up elsewhere. + actions.push(action(localize('agentHost.sdkSetup.gitHubSignInAction', "Sign in to GitHub"), AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID)); + return { + ...base, + message: localize('agentHost.sdkSetup.noAccount', "Choose how you want to use {0}.", displayName), + description: noAccountDescription(setup, displayName), + actions, + }; +} + +// #endregion + +// #region Commands + +export const AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID = 'workbench.action.chat.agentHost.downloadAgentSdk'; +export const AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID = 'workbench.action.chat.agentHost.openAgentSetupDocs'; +export const AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID = 'workbench.action.chat.agentHost.signInToGitHubForAgent'; +export const AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID = 'workbench.action.chat.agentHost.signInToAgent'; + +/** + * The banner's buttons. Commands rather than inline handlers because + * {@link IChatInputNotification} actions address commands by id, and each takes + * the agent id and nothing else — what a route needs beyond that is resolved by + * the service from the agent's own declaration, not from the banner's copy. + */ +function registerAgentSdkSetupCommand(id: string, run: (setupService: IAgentSdkSetupService, agent: string) => void): void { + CommandsRegistry.registerCommand(id, (accessor: ServicesAccessor, agent: unknown) => { + if (typeof agent === 'string') { + run(accessor.get(IAgentSdkSetupService), agent); + } + }); +} + +registerAgentSdkSetupCommand(AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, (setupService, agent) => setupService.requestDownload(agent)); +registerAgentSdkSetupCommand(AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, (setupService, agent) => setupService.openSetupDocs(agent)); +registerAgentSdkSetupCommand(AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, (setupService, agent) => setupService.signInToGitHub(agent)); +registerAgentSdkSetupCommand(AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, (setupService, agent) => setupService.signIn(agent)); + +// #endregion + +/** + * Offers the SDK download, and explains a missing account once it is on disk, + * for every agent whose setup lives outside the app. + * + * Sibling to `AgentHostSignedOutModelsNotification`, which stays Copilot-scoped + * — these are different asks aimed at different people and share only the + * notification machinery. + */ +export class AgentHostSdkSetupNotificationContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostSdkSetupNotification'; + + /** Pushed notification content by id, so an unchanged answer is not re-pushed (which would clear a dismissal and re-announce). */ + private readonly _shown = new Map(); + + /** Last state reported per agent, so a re-render is not a second event. */ + private readonly _lastReported = new Map(); + + constructor( + @IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService, + @IAgentSdkSetupService private readonly _agentSdkSetupService: IAgentSdkSetupService, + @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + @IAgentHostService private readonly _agentHostService: IAgentHostService, + ) { + super(); + this._register(Event.any( + this._agentSdkSetupService.onDidChangeSetups, + this._chatEntitlementService.onDidChangeEntitlement, + this._defaultAccountService.onDidChangeDefaultAccount, + this._languageModelsService.onDidChangeLanguageModels, + Event.filter(this._configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(AgentHostAllowSignedOutWhenUsableSettingId)), + )(() => this._update())); + // The host restarts (and a remote reconnects) behind a fresh root state, so + // re-bind rather than holding one subscription for the window's lifetime. + const rootStateListeners = this._register(new DisposableStore()); + const bindRootState = () => { + rootStateListeners.clear(); + rootStateListeners.add(this._agentHostService.rootState.onDidChange(() => this._update())); + this._update(); + }; + bindRootState(); + this._register(this._agentHostService.onAgentHostStart(bindRootState)); + } + + private _update(): void { + const allowSignedOutWhenUsable = this._configurationService.getValue(AgentHostAllowSignedOutWhenUsableSettingId) === true; + const entitlement = this._chatEntitlementService.entitlement; + const entitlementResolved = entitlement !== ChatEntitlement.Unresolved; + const signedIn = this._defaultAccountService.currentDefaultAccount !== null + || (entitlementResolved && entitlement !== ChatEntitlement.Unknown); + const displayNames = getAgentDisplayNames(this._agentHostService.rootState.value); + const stale = new Set(this._shown.keys()); + for (const setup of this._agentSdkSetupService.setups) { + // An agent can publish its setup status before root state lists it, so a + // missing name here means "not yet", not "never" — and every root-state + // change re-runs this. + const displayName = displayNames.get(setup.agent); + if (!displayName) { + continue; + } + const state = getAgentSdkSetupState({ + allowSignedOutWhenUsable, + signedIn, + entitlementResolved, + download: setup.download, + downloadRequested: this._agentSdkSetupService.isDownloadPending(setup.agent), + hasModels: hasAnyModelTargetingSessionType(this._languageModelsService, agentSdkSetupSessionType(setup.agent)), + }); + // Before the render decision below, because `resolved` — the step the + // funnel exists to count — is exactly the state that renders nothing. + const toReport = getAgentSdkSetupStateToReport(this._lastReported.get(setup.agent), state); + if (toReport) { + this._lastReported.set(setup.agent, toReport); + this._agentSdkSetupService.reportSetupState(setup.agent, toReport); + } + const notification = createAgentSdkSetupNotification(setup, displayName, state); + if (!notification) { + continue; + } + stale.delete(notification.id); + const signature = JSON.stringify(notification); + if (this._shown.get(notification.id) === signature) { + continue; + } + this._shown.set(notification.id, signature); + this._chatInputNotificationService.setNotification(notification); + } + for (const id of stale) { + this._shown.delete(id); + this._chatInputNotificationService.deleteNotification(id); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.ts index 4b2aa3d18886d..b06795a42a16d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.ts @@ -24,10 +24,28 @@ export enum SessionTypeAvailability { NoModels, } -export function getSessionTypePickerAvailability(type: string, availability: SessionTypeAvailability, allowSignedOutWhenUsable: boolean): SessionTypeAvailability { - return allowSignedOutWhenUsable && type === SessionType.AgentHostCopilot && availability === SessionTypeAvailability.SignInRequired - ? SessionTypeAvailability.Available - : availability; +/** + * The picker's view of {@link getSessionTypeAvailability}, which keeps a harness + * selectable in the two cases where the raw answer would grey out something the + * user can still act on. + * + * `hasSetupBanner` is the second: a harness whose SDK setup banner is on offer + * has no models *yet*, and the banner saying how to fix that renders inside a + * session of that very type. Not a static allow-list of session types — a + * signed-in user whose Claude harness has no models gets no banner and stays + * greyed out, which is the honest answer for them. + */ +export function getSessionTypePickerAvailability(type: string, availability: SessionTypeAvailability, allowSignedOutWhenUsable: boolean, hasSetupBanner: boolean): SessionTypeAvailability { + if (!allowSignedOutWhenUsable) { + return availability; + } + if (type === SessionType.AgentHostCopilot && availability === SessionTypeAvailability.SignInRequired) { + return SessionTypeAvailability.Available; + } + if (hasSetupBanner && availability === SessionTypeAvailability.NoModels) { + return SessionTypeAvailability.Available; + } + return availability; } /** @@ -71,7 +89,7 @@ export function getSessionTypeAvailability( return SessionTypeAvailability.Available; } const entitlement = chatEntitlementService.entitlement; - const hasTargetedModels = hasModelsTargetingSessionType(languageModelsService, type); + const hasTargetedModels = hasAnyModelTargetingSessionType(languageModelsService, type); const hasVisibleByokModels = allowSignedOutWhenUsable && chatEntitlementService.clientByokEnabled && hasVisibleByokModelsTargetingSessionType(languageModelsService, type); // A visible Agent Host BYOK model can run without a Copilot account. if (entitlement === ChatEntitlement.Unknown && !chatEntitlementService.anonymous && chatSessionsService.requiresCopilotSignInForSessionType(type) && !hasVisibleByokModels) { @@ -100,7 +118,7 @@ export function getSessionTypeAvailability( * type (e.g. a user-configured BYOK model). General-pool models are ignored * since a session type that requires its own models cannot use them. */ -function hasModelsTargetingSessionType(languageModelsService: ILanguageModelsService, type: string): boolean { +export function hasAnyModelTargetingSessionType(languageModelsService: ILanguageModelsService, type: string): boolean { return languageModelsService.getLanguageModelIds().some(id => { const metadata = languageModelsService.lookupLanguageModel(id); return metadata?.targetChatSessionType === type; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index b156bebc3233c..3ebd29d0d0372 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -27,6 +27,7 @@ import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentCanContinueIn, getAgentSessionProvider, isAgentHostTarget, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; +import { IChatInputNotificationService } from './chatInputNotificationService.js'; import { ISessionTypeItem, SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { IGitService } from '../../../../git/common/gitService.js'; @@ -54,9 +55,10 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt @IStorageService storageService: IStorageService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, + @IChatInputNotificationService chatInputNotificationService: IChatInputNotificationService, @IGitService private readonly gitService: IGitService, ) { - super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService, chatEntitlementService, languageModelsService, configurationService, storageService, workspaceContextService, agentHostEnablementService); + super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService, chatEntitlementService, languageModelsService, configurationService, storageService, workspaceContextService, agentHostEnablementService, chatInputNotificationService); } protected override _run(sessionTypeItem: ISessionTypeItem): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index d97be275daa35..71f0e87cb0e1b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -32,6 +32,8 @@ import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { getSessionTypeAvailability, getSessionTypePickerAvailability, getSessionTypeUnavailableDescription, getSessionTypeUnavailableHover, SessionTypeAvailability } from '../../agentSessions/sessionTypeAvailability.js'; +import { hasAgentSdkSetupNotification } from '../../agentSessions/agentHost/agentHostSdkSetupNotification.js'; +import { IChatInputNotificationService } from './chatInputNotificationService.js'; import { ChatConfiguration, getDefaultNewChatSessionType, isVisibleEditorChatSessionType, recordUserSelectedSessionType } from '../../../common/constants.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; @@ -91,12 +93,14 @@ export function getConfiguredSessionTypePickerAvailability( chatSessionsService: IChatSessionsService, chatEntitlementService: IChatEntitlementService, languageModelsService: ILanguageModelsService, + chatInputNotificationService: IChatInputNotificationService, ): SessionTypeAvailability { const allowSignedOutWhenUsable = configurationService.getValue(AgentHostAllowSignedOutWhenUsableSettingId) === true; return getSessionTypePickerAvailability( type, getSessionTypeAvailability(chatSessionsService, chatEntitlementService, languageModelsService, type, allowSignedOutWhenUsable), allowSignedOutWhenUsable, + hasAgentSdkSetupNotification(chatInputNotificationService, type), ); } @@ -126,6 +130,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { @IStorageService protected readonly storageService: IStorageService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, + @IChatInputNotificationService protected readonly chatInputNotificationService: IChatInputNotificationService, ) { const actionProvider: IActionWidgetDropdownActionProvider = { @@ -140,6 +145,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { this.chatSessionsService, this.chatEntitlementService, this.languageModelsService, + this.chatInputNotificationService, ); actions.push(createSessionTypePickerAction( action, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts new file mode 100644 index 0000000000000..ccf3311fbacdb --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import type { IAgentSdkSetupInfo } from '../../../../../../platform/agentHost/common/agentSdkSetup.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, agentSdkSetupNotificationId, createAgentSdkSetupNotification, getAgentDisplayNames, getAgentSdkSetupState, getAgentSdkSetupStateToReport, hasAgentSdkSetupNotification, type IAgentSdkSetupStateInputs } from '../../../browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; +import type { AgentSdkSetupState } from '../../../../../services/agentHost/browser/agentSdkSetupService.js'; +import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, type IChatInputNotification, type IChatInputNotificationAction, type IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js'; +import { SessionType } from '../../../common/chatSessionsService.js'; + +/** Signed out, flag on, entitlement settled, SDK missing — the case this feature exists for. */ +const BLOCKED_USER: IAgentSdkSetupStateInputs = { + allowSignedOutWhenUsable: true, + signedIn: false, + entitlementResolved: true, + download: 'notDownloaded', + downloadRequested: false, + hasModels: false, +}; + +function commandIds(actions: readonly IChatInputNotificationAction[]): string[] { + return actions.map(action => action.kind === ChatInputNotificationActionKind.Command ? action.commandId : action.kind); +} + +suite('Agent SDK setup banner', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('state', () => { + const cases: readonly { readonly name: string; readonly inputs: IAgentSdkSetupStateInputs; readonly expected: AgentSdkSetupState | undefined }[] = [ + { name: 'signed-out user with no SDK is offered the download', inputs: BLOCKED_USER, expected: 'downloadOffered' }, + { name: 'a fetch in flight has nothing to ask for, since the host shows its own progress', inputs: { ...BLOCKED_USER, download: 'downloading' }, expected: undefined }, + // The host answers a download request over IPC, so it keeps saying + // `notDownloaded` for a moment after we ask. Offering the button again in + // that gap would re-ask a user who has already consented. + { name: 'a request the host has not answered yet is not a fresh offer', inputs: { ...BLOCKED_USER, downloadRequested: true }, expected: undefined }, + { name: 'SDK on disk reporting no models means no account', inputs: { ...BLOCKED_USER, download: 'ready' }, expected: 'noAccount' }, + { name: 'models are the honest end state, whatever the status says', inputs: { ...BLOCKED_USER, download: 'ready', hasModels: true }, expected: 'resolved' }, + { name: 'a signed-in user already has Copilot models', inputs: { ...BLOCKED_USER, signedIn: true }, expected: undefined }, + { name: 'nothing shows until entitlement settles, since "signed out" is not yet a fact', inputs: { ...BLOCKED_USER, entitlementResolved: false }, expected: undefined }, + { name: 'the whole feature stays behind its flag', inputs: { ...BLOCKED_USER, allowSignedOutWhenUsable: false }, expected: undefined }, + { name: 'a signed-in user mid-download is still shown nothing', inputs: { ...BLOCKED_USER, signedIn: true, download: 'downloading' }, expected: undefined }, + ]; + + for (const { name, inputs, expected } of cases) { + test(name, () => { + assert.strictEqual(getAgentSdkSetupState(inputs), expected); + }); + } + }); + + suite('presentation', () => { + const claude: IAgentSdkSetupInfo = { agent: 'claude', download: 'notDownloaded', setupDocsUrl: 'https://example.test/claude' }; + + test('the download offer names the SDK, explains it, and carries a single Download button', () => { + const notification = createAgentSdkSetupNotification(claude, 'Claude', 'downloadOffered'); + + assert.ok(notification); + assert.strictEqual(notification.id, agentSdkSetupNotificationId('claude')); + assert.deepStrictEqual(notification.sessionTypes, [SessionType.AgentHostClaude]); + assert.strictEqual(notification.message, 'Download the Claude Agent'); + // An ask that expects a decision explains itself, and does so without + // tying the SDK to an account: the same download serves the Copilot + // proxy, a Claude subscription and a BYO key alike. + assert.strictEqual(notification.description, 'To use the Claude Agent, we need to download the Claude Agent SDK.'); + assert.deepStrictEqual(commandIds(notification.actions), [AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID]); + assert.deepStrictEqual(notification.actions[0].kind === ChatInputNotificationActionKind.Command ? notification.actions[0].commandArgs : undefined, ['claude']); + }); + + test('every noun comes from the agent, so a second agent needs no entry here', () => { + const codex: IAgentSdkSetupInfo = { agent: 'codex', download: 'notDownloaded', signInProviderName: 'ChatGPT' }; + + assert.deepStrictEqual({ + sessionTypes: createAgentSdkSetupNotification(codex, 'Codex', 'downloadOffered')?.sessionTypes, + download: createAgentSdkSetupNotification(codex, 'Codex', 'downloadOffered')?.message, + noAccount: createAgentSdkSetupNotification(codex, 'Codex', 'noAccount')?.message, + }, { + sessionTypes: [SessionType.AgentHostCodex], + download: 'Download the Codex Agent', + noAccount: 'Choose how you want to use Codex.', + }); + }); + + test('a missing account offers every route the agent declared, GitHub sign-in last', () => { + // Last is the primary button in the widget, and GitHub is the route that + // works whatever the user has (or has not) set up elsewhere. + const codex: IAgentSdkSetupInfo = { agent: 'codex', download: 'ready', setupDocsUrl: 'https://example.test/codex', signInProviderName: 'ChatGPT' }; + const buttons = (setup: IAgentSdkSetupInfo, displayName: string) => + commandIds(createAgentSdkSetupNotification(setup, displayName, 'noAccount')?.actions ?? []); + + assert.deepStrictEqual({ + docsOnly: buttons({ ...claude, download: 'ready' }, 'Claude'), + signInOnly: buttons({ ...codex, setupDocsUrl: undefined }, 'Codex'), + both: buttons(codex, 'Codex'), + neither: buttons({ agent: 'some-future-agent', download: 'ready' }, 'Future'), + }, { + docsOnly: [AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], + signInOnly: [AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], + both: [AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], + neither: [AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], + }); + }); + + test('every button is addressed to the agent, and the sign-in one is labelled by its provider', () => { + const notification = createAgentSdkSetupNotification({ agent: 'codex', download: 'ready', signInProviderName: 'ChatGPT' }, 'Codex', 'noAccount'); + + assert.ok(notification); + // The agent id, not the URL or the provider: each command resolves what it + // needs from the agent's own declaration rather than trusting the banner. + assert.deepStrictEqual(notification.actions.map(action => action.kind === ChatInputNotificationActionKind.Command ? action.commandArgs : undefined), [['codex'], ['codex']]); + assert.deepStrictEqual(notification.actions.map(action => action.label), ['Sign in to ChatGPT', 'Sign in to GitHub']); + }); + + test('the routes named in the copy are the ones the agent declared', () => { + // One whole sentence per combination rather than joined clauses, since a + // translator reorders them freely. GitHub appears in all four: every agent + // behind this banner reaches models through our proxy once signed in. + const noAccount = (setup: Omit) => + createAgentSdkSetupNotification({ agent: 'claude', download: 'ready', ...setup }, 'Claude', 'noAccount')?.description; + + assert.deepStrictEqual({ + gitHubOnly: noAccount({}), + docs: noAccount({ setupDocsUrl: 'https://example.test/claude' }), + signIn: noAccount({ signInProviderName: 'ChatGPT' }), + both: noAccount({ setupDocsUrl: 'https://example.test/claude', signInProviderName: 'ChatGPT' }), + }, { + gitHubOnly: 'Sign in to GitHub to use GitHub Copilot models.', + docs: 'Sign in to GitHub to use GitHub Copilot models, or read the instructions for other ways to set up Claude.', + signIn: 'Sign in to GitHub to use GitHub Copilot models, or sign in to ChatGPT to use your ChatGPT subscription.', + both: 'Sign in to GitHub to use GitHub Copilot models, sign in to ChatGPT to use your ChatGPT subscription, or read the instructions for other ways to set up Claude.', + }); + }); + + test('the banner cannot be dismissed, since it is the only route to a working agent', () => { + const notification = createAgentSdkSetupNotification(claude, 'Claude', 'downloadOffered'); + + assert.ok(notification); + assert.strictEqual(notification.dismissible, false); + assert.strictEqual(notification.autoDismissOnMessage, false); + }); + + test('nothing is rendered once the user is set up, or for an agent the host has not named yet', () => { + assert.strictEqual(createAgentSdkSetupNotification(claude, 'Claude', undefined), undefined); + assert.strictEqual(createAgentSdkSetupNotification({ ...claude, download: 'ready' }, 'Claude', 'resolved'), undefined); + // "Download the Agent" is worse than no banner; the next root-state + // change carries the name. + assert.strictEqual(createAgentSdkSetupNotification({ agent: 'some-future-agent', download: 'notDownloaded' }, '', 'downloadOffered'), undefined); + }); + }); + + suite('display names', () => { + test('reads each agent name the host published, and skips what it did not', () => { + assert.deepStrictEqual([...getAgentDisplayNames({ + agents: [ + { provider: 'claude', displayName: 'Claude', description: '', models: [] }, + { provider: 'nameless', displayName: '', description: '', models: [] }, + ], + })], [['claude', 'Claude']]); + }); + + test('a host that has not reported, or failed, names nobody', () => { + assert.deepStrictEqual([...getAgentDisplayNames(undefined)], []); + assert.deepStrictEqual([...getAgentDisplayNames(new Error('host is down'))], []); + }); + }); + + suite('reachability', () => { + /** A notification service holding the given notifications, none dismissed. */ + function notificationService(notifications: readonly IChatInputNotification[]): IChatInputNotificationService { + return new class extends mock() { + override getActiveNotification(filter?: (notification: IChatInputNotification) => boolean): IChatInputNotification | undefined { + return notifications.find(notification => !filter || filter(notification)); + } + }(); + } + + function bannersFor(...agents: readonly string[]): readonly IChatInputNotification[] { + return agents.flatMap(agent => { + const notification = createAgentSdkSetupNotification({ agent, download: 'notDownloaded' }, agent, 'downloadOffered'); + return notification ? [notification] : []; + }); + } + + test('a banner is found for the session type it is scoped to, and only that one', () => { + const service = notificationService(bannersFor('claude')); + + assert.deepStrictEqual({ + claude: hasAgentSdkSetupNotification(service, SessionType.AgentHostClaude), + codex: hasAgentSdkSetupNotification(service, SessionType.AgentHostCodex), + copilot: hasAgentSdkSetupNotification(service, SessionType.AgentHostCopilot), + }, { claude: true, codex: false, copilot: false }); + }); + + test('an unscoped notification is not mistaken for a setup banner', () => { + // The session-type filter alone passes a notification with no + // `sessionTypes` — a quota warning applies everywhere — so the id + // carries the "this is a setup ask" bit. + const service = notificationService([{ + id: 'chat.quotaExceeded', + severity: ChatInputNotificationSeverity.Warning, + message: 'Out of quota', + description: undefined, + actions: [], + dismissible: true, + autoDismissOnMessage: false, + }]); + + assert.strictEqual(hasAgentSdkSetupNotification(service, SessionType.AgentHostClaude), false); + }); + + test('nothing on offer means nothing to reach', () => { + assert.strictEqual(hasAgentSdkSetupNotification(notificationService([]), SessionType.AgentHostClaude), false); + }); + }); + + suite('funnel', () => { + const cases: readonly { + readonly name: string; + /** The last state *reported* for this agent, not the last one computed. */ + readonly previous: AgentSdkSetupState | undefined; + readonly state: AgentSdkSetupState | undefined; + readonly expected: AgentSdkSetupState | undefined; + }[] = [ + { name: 'first sight of the offer counts', previous: undefined, state: 'downloadOffered', expected: 'downloadOffered' }, + { name: 'an SDK that found no account is where users get stuck', previous: 'downloadOffered', state: 'noAccount', expected: 'noAccount' }, + { name: 'a stuck user who then has models is the conversion', previous: 'noAccount', state: 'resolved', expected: 'resolved' }, + // Counted once per user: re-renders are constant, and a download that + // failed back to the offer is the same person still being asked. + { name: 'a re-render, or a failed download returning to the offer, is not a second offer', previous: 'downloadOffered', state: 'downloadOffered', expected: undefined }, + { name: 'a conversion is not re-counted on every later render', previous: 'resolved', state: 'resolved', expected: undefined }, + { name: 'a fetch in flight, or giving up, moves the user nowhere', previous: 'downloadOffered', state: undefined, expected: undefined }, + { name: 'a user this feature was never for is not a convert', previous: undefined, state: 'resolved', expected: undefined }, + ]; + + for (const { name, previous, state, expected } of cases) { + test(name, () => { + assert.strictEqual(getAgentSdkSetupStateToReport(previous, state), expected); + }); + } + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/sessionTypeAvailability.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/sessionTypeAvailability.test.ts index 48fcbb51ff3a3..5faad77b5a52c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/sessionTypeAvailability.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/sessionTypeAvailability.test.ts @@ -104,7 +104,7 @@ suite('getSessionTypeAvailability', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('Copilot Agent Host remains setup-selectable when signed-out operation is enabled', () => { - const pickerAvailability = (type: string, allowSignedOutWhenUsable: boolean) => getSessionTypePickerAvailability(type, SessionTypeAvailability.SignInRequired, allowSignedOutWhenUsable); + const pickerAvailability = (type: string, allowSignedOutWhenUsable: boolean) => getSessionTypePickerAvailability(type, SessionTypeAvailability.SignInRequired, allowSignedOutWhenUsable, false); assert.deepStrictEqual({ localCopilot: pickerAvailability(SessionType.AgentHostCopilot, true), localClaude: pickerAvailability(SessionType.AgentHostClaude, true), @@ -118,6 +118,37 @@ suite('getSessionTypeAvailability', () => { }); }); + suite('a harness with a setup banner stays selectable', () => { + // The banner renders inside a session of the type it is scoped to, so + // greying the harness out would hide the only route to it. + const pickerAvailability = (availability: SessionTypeAvailability, hasSetupBanner: boolean, allowSignedOutWhenUsable = true) => + getSessionTypePickerAvailability(SessionType.AgentHostClaude, availability, allowSignedOutWhenUsable, hasSetupBanner); + + test('a signed-out user with no Claude models can still pick the harness the banner belongs to', () => { + assert.strictEqual(pickerAvailability(SessionTypeAvailability.NoModels, true), SessionTypeAvailability.Available); + }); + + test('the same harness with no banner stays greyed out, since there is nothing to send the user to', () => { + // e.g. a signed-in user whose Claude harness has no models: the banner + // is deliberately hidden for them, so "No models available" is honest. + assert.strictEqual(pickerAvailability(SessionTypeAvailability.NoModels, false), SessionTypeAvailability.NoModels); + }); + + test('a banner does not unlock a harness the user must sign in or upgrade for', () => { + assert.deepStrictEqual({ + signIn: pickerAvailability(SessionTypeAvailability.SignInRequired, true), + upgrade: pickerAvailability(SessionTypeAvailability.UpgradeRequired, true), + }, { + signIn: SessionTypeAvailability.SignInRequired, + upgrade: SessionTypeAvailability.UpgradeRequired, + }); + }); + + test('the whole override stays behind the signed-out opt-in', () => { + assert.strictEqual(pickerAvailability(SessionTypeAvailability.NoModels, true, false), SessionTypeAvailability.NoModels); + }); + }); + function availability(config: ITypeConfig, entitlement: ChatEntitlement, modelTargets: readonly (string | undefined)[] = [], anonymous = false): SessionTypeAvailability { return getSessionTypeAvailability( createChatSessionsService(config), diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts index 63bffc6542557..4558d92cc9d27 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts @@ -13,7 +13,9 @@ import { AgentHostAllowSignedOutWhenUsableSettingId } from '../../../../../../.. import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; import { AgentSessionProviders, getAgentSessionProviderDescription } from '../../../../browser/agentSessions/agentSessions.js'; +import { createAgentSdkSetupNotification } from '../../../../browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; import { SessionTypeAvailability } from '../../../../browser/agentSessions/sessionTypeAvailability.js'; +import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../../../browser/widget/input/chatInputNotificationService.js'; import { IChatSessionsService, ResolvedChatSessionsExtensionPoint, SessionType } from '../../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../common/languageModels.js'; import { createSessionTypePickerAction, getConfiguredSessionTypePickerAvailability, ISessionTypeItem } from '../../../../browser/widget/input/sessionTargetPickerActionItem.js'; @@ -40,15 +42,25 @@ function getMarkdownValue(value: string | IMarkdownString | HTMLElement | undefi return typeof value === 'string' ? value : value instanceof HTMLElement ? value.textContent ?? undefined : value?.value; } -function getCopilotAvailability(allowSignedOutWhenUsable: boolean): SessionTypeAvailability { +interface IAvailabilityInputs { + readonly type: string; + readonly allowSignedOutWhenUsable: boolean; + /** Whether the harness is gated on a Copilot account. */ + readonly requiresCopilotSignIn: boolean; + /** Notifications currently on offer, none dismissed. */ + readonly notifications?: readonly IChatInputNotification[]; +} + +/** Availability for a signed-out user whose harness needs its own models and has none. */ +function getSignedOutAvailability({ type, allowSignedOutWhenUsable, requiresCopilotSignIn, notifications = [] }: IAvailabilityInputs): SessionTypeAvailability { const chatSessionsService = new class extends mock() { - override getChatSessionContribution(type: string): ResolvedChatSessionsExtensionPoint | undefined { - return type === SessionType.AgentHostCopilot + override getChatSessionContribution(candidate: string): ResolvedChatSessionsExtensionPoint | undefined { + return candidate === type ? { type, name: type, displayName: type, description: '', icon: undefined } : undefined; } override requiresCopilotSignInForSessionType(): boolean { - return true; + return requiresCopilotSignIn; } override supportsAutoModelForSessionType(): boolean { return false; @@ -73,16 +85,32 @@ function getCopilotAvailability(allowSignedOutWhenUsable: boolean): SessionTypeA return []; } }(); + const notificationService = new class extends mock() { + override getActiveNotification(filter?: (notification: IChatInputNotification) => boolean): IChatInputNotification | undefined { + return notifications.find(notification => !filter || filter(notification)); + } + }(); return getConfiguredSessionTypePickerAvailability( - SessionType.AgentHostCopilot, + type, new TestConfigurationService({ [AgentHostAllowSignedOutWhenUsableSettingId]: allowSignedOutWhenUsable }), chatSessionsService, entitlementService, languageModelsService, + notificationService, ); } +function getCopilotAvailability(allowSignedOutWhenUsable: boolean): SessionTypeAvailability { + return getSignedOutAvailability({ type: SessionType.AgentHostCopilot, allowSignedOutWhenUsable, requiresCopilotSignIn: true }); +} + +/** The real banner, so the test is bound to the ids and session scoping it actually publishes. */ +function claudeSetupBanner(): readonly IChatInputNotification[] { + const notification = createAgentSdkSetupNotification({ agent: 'claude', download: 'notDownloaded' }, 'Claude', 'downloadOffered'); + return notification ? [notification] : []; +} + suite('SessionTypePickerActionItem', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -96,6 +124,54 @@ suite('SessionTypePickerActionItem', () => { }); }); + test('a harness whose SDK setup banner is on offer stays selectable, so the banner can be reached', () => { + // The Claude harness no longer requires a Copilot account, so a signed-out + // user with no Claude models lands on "No models available" — and the banner + // telling them how to fix that only renders inside a Claude session. + const claude = (notifications: readonly IChatInputNotification[]) => getSignedOutAvailability({ + type: SessionType.AgentHostClaude, + allowSignedOutWhenUsable: true, + requiresCopilotSignIn: false, + notifications, + }); + + assert.deepStrictEqual({ + withBanner: claude(claudeSetupBanner()), + withoutBanner: claude([]), + }, { + withBanner: SessionTypeAvailability.Available, + withoutBanner: SessionTypeAvailability.NoModels, + }); + }); + + test('another agent\'s setup banner does not unlock this harness', () => { + assert.strictEqual(getSignedOutAvailability({ + type: SessionType.AgentHostCodex, + allowSignedOutWhenUsable: true, + requiresCopilotSignIn: false, + notifications: claudeSetupBanner(), + }), SessionTypeAvailability.NoModels); + }); + + test('an unscoped notification does not unlock a harness that has nothing to offer', () => { + // `getActiveNotification`'s session-type filter passes notifications with no + // `sessionTypes` at all (a quota warning, say) — those must not read as setup. + assert.strictEqual(getSignedOutAvailability({ + type: SessionType.AgentHostClaude, + allowSignedOutWhenUsable: true, + requiresCopilotSignIn: false, + notifications: [{ + id: 'chat.quotaExceeded', + severity: ChatInputNotificationSeverity.Warning, + message: 'Out of quota', + description: undefined, + actions: [], + dismissible: true, + autoDismissOnMessage: false, + }], + }), SessionTypeAvailability.NoModels); + }); + test('creates an available Codex extension action with hover context', () => { const item = createCodexItem(AgentSessionProviders.Codex); const action = createSessionTypePickerAction( diff --git a/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts b/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts new file mode 100644 index 0000000000000..f47ab455febcd --- /dev/null +++ b/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts @@ -0,0 +1,258 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, IAgentSdkSetupInfo, readAgentSdkSetupInfos, readConsentedSdkAgents, resolveConsentedSdkDownloads, writeConsentedSdkAgents } from '../../../../platform/agentHost/common/agentSdkSetup.js'; +import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; +import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { ROOT_STATE_URI } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ICodexAccountService } from './codexAccountService.js'; + +/** + * The agents whose SDK the user has agreed to fetch, each recorded on its own + * first explicit Download. `APPLICATION` + `USER` so it follows the person, not + * the machine — see {@link resolveConsentedSdkDownloads} for why it is neither + * re-asked per version nor shared between agents. + */ +const AGENT_SDK_DOWNLOAD_CONSENT_KEY = 'agentHost.agentSdkDownloadConsent'; + +/** The Copilot sign-in flow, shared with `AgentHostSignedOutModelsNotification`. */ +const CHAT_SETUP_COMMAND_ID = 'workbench.action.chat.triggerSetup'; + +export const IAgentSdkSetupService = createDecorator('agentSdkSetupService'); + +/** + * Where the user stands with one agent's setup: the download is on offer, the + * SDK is on disk and found no account, or the agent has models. Every other + * case — the feature not applying, a fetch in flight — is `undefined`. + */ +export type AgentSdkSetupState = 'downloadOffered' | 'noAccount' | 'resolved'; + +/** + * One step of the setup funnel: `downloadOffered` → a download (clicked, or + * taken under standing consent) → `noAccount` → a route out of it → + * `resolved`, the step that decides whether this was worth building. The states + * are reported by the banner that computes them, the routes by this service. + */ +type AgentSdkSetupFunnelStep = + | AgentSdkSetupState + | 'downloadClicked' + | 'consentedDownload' + | 'docsClicked' + | 'gitHubSignInClicked' + | 'signInClicked'; + +interface IAgentSdkSetupFunnelEvent { + agent: string; + step: string; +} + +type AgentSdkSetupFunnelClassification = { + agent: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent whose setup this step belongs to, e.g. claude or codex.' }; + step: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which step of the agent SDK setup funnel was reached (downloadOffered, downloadClicked, consentedDownload, noAccount, docsClicked, gitHubSignInClicked, signInClicked, resolved).' }; + owner: 'TylerLeonhardt'; + comment: 'Tracks how far a signed-out user gets through setting up their own Claude or Codex account.'; +}; + +export interface IAgentSdkSetupService { + readonly _serviceBrand: undefined; + + /** Every agent that has published a setup status, newest state. */ + readonly setups: readonly IAgentSdkSetupInfo[]; + readonly onDidChangeSetups: Event; + + /** + * Ask `agent` to fetch its SDK, and record standing consent to do so again + * for later version bumps. + */ + requestDownload(agent: string): void; + + /** Open the setup instructions `agent` published, if it published any. */ + openSetupDocs(agent: string): void; + + /** Start GitHub sign-in, which reaches every agent's models through our proxy. */ + signInToGitHub(agent: string): void; + + /** Start `agent`'s own sign-in flow, if it declared one. */ + signIn(agent: string): void; + + /** + * Whether `agent` has been asked to fetch its SDK and the host has not + * answered yet — already downloading, as far as this window can tell. + */ + isDownloadPending(agent: string): boolean; + + /** + * Record that the user reached `state`. Public because the banner is + * where these three are computed and this service cannot see them; every other + * step is reported by the method that takes it. + */ + reportSetupState(agent: string, state: AgentSdkSetupState): void; +} + +class AgentSdkSetupService extends Disposable implements IAgentSdkSetupService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeSetups = this._register(new Emitter()); + readonly onDidChangeSetups = this._onDidChangeSetups.event; + + private _setups: readonly IAgentSdkSetupInfo[] = []; + + /** + * Agents whose SDK we have already re-requested under standing consent, so a + * download that fails (and so reports `notDownloaded` again) is retried on the + * next window rather than immediately, forever. + */ + private readonly _consentedRequests = new Set(); + + /** + * Agents we have asked to fetch and the host has not answered yet. Cleared on + * that answer rather than on success, so a failed download — which republishes + * `notDownloaded` after the `downloading` we cleared on — re-offers the button. + */ + private readonly _pendingRequests = new Set(); + + get setups(): readonly IAgentSdkSetupInfo[] { + return this._setups; + } + + constructor( + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IStorageService private readonly _storageService: IStorageService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + @IOpenerService private readonly _openerService: IOpenerService, + @ICommandService private readonly _commandService: ICommandService, + @ICodexAccountService private readonly _codexAccountService: ICodexAccountService, + ) { + super(); + // `rootState` is a getter over a protocol client the host replaces on every + // restart and reconnect, so one subscription taken here would go quietly + // stale — re-bind, as the banner and the Copilot notification both do. + const rootStateListeners = this._register(new DisposableStore()); + const bindRootState = () => { + rootStateListeners.clear(); + rootStateListeners.add(this._agentHostService.rootState.onDidChange(state => this._updateSetups(readAgentSdkSetupInfos(state)))); + // A request the previous host never answered never will be; dropping it + // re-offers the button rather than suppressing the offer for good. + this._pendingRequests.clear(); + const state = this._agentHostService.rootState.value; + this._updateSetups(readAgentSdkSetupInfos(state instanceof Error ? undefined : state)); + }; + bindRootState(); + this._register(this._agentHostService.onAgentHostStart(bindRootState)); + } + + requestDownload(agent: string): void { + const consented = new Set(this._readConsentedAgents()); + consented.add(agent); + this._storageService.store(AGENT_SDK_DOWNLOAD_CONSENT_KEY, writeConsentedSdkAgents(consented), StorageScope.APPLICATION, StorageTarget.USER); + this._consentedRequests.add(agent); + this._reportStep(agent, 'downloadClicked'); + this._dispatchDownloadRequest(agent); + } + + openSetupDocs(agent: string): void { + const url = this._getSetup(agent)?.setupDocsUrl; + if (!url) { + return; + } + this._reportStep(agent, 'docsClicked'); + // The URL is declared by the agent, so it is validated like any other + // externally-supplied link rather than trusted. + void this._openerService.open(url, { openExternal: true }); + } + + signInToGitHub(agent: string): void { + // A thin wrapper over the ordinary Copilot sign-in, taking the agent id only + // to attribute the click — which is the funnel's most telling drop. + this._reportStep(agent, 'gitHubSignInClicked'); + void this._commandService.executeCommand(CHAT_SETUP_COMMAND_ID); + } + + signIn(agent: string): void { + // Codex is the only agent with an in-app sign-in today, and comparing against + // the service's own `agent` rather than a literal keeps `'codex'` out of the + // workbench. A second such agent turns this comparison into a lookup. + if (agent !== this._codexAccountService.agent) { + return; + } + this._reportStep(agent, 'signInClicked'); + this._codexAccountService.signIn(); + } + + reportSetupState(agent: string, state: AgentSdkSetupState): void { + this._reportStep(agent, state); + } + + isDownloadPending(agent: string): boolean { + return this._pendingRequests.has(agent); + } + + private _reportStep(agent: string, step: AgentSdkSetupFunnelStep): void { + this._telemetryService.publicLog2('agentHost.agentSdkSetup', { agent, step }); + // This feature is diagnosed from a user's attached log far more often than + // from a dashboard; the event says how many, this line says why this person. + this._logService.trace(`[AgentSdkSetup] ${agent}: ${step}`); + } + + private _getSetup(agent: string): IAgentSdkSetupInfo | undefined { + return this._setups.find(setup => setup.agent === agent); + } + + private _dispatchDownloadRequest(agent: string): void { + this._pendingRequests.add(agent); + // A fresh nonce every time so pressing the same button twice is two + // requests; the agent clears the key as it consumes it. + this._agentHostService.dispatch(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request: generateUuid() } }, + }); + // The statuses are unchanged but {@link isDownloadPending} is not, and + // without this the offer stays up until the host answers — the flicker the + // pending set exists to prevent. + this._onDidChangeSetups.fire(this._setups); + } + + private _updateSetups(setups: readonly IAgentSdkSetupInfo[]): void { + this._setups = setups; + for (const setup of setups) { + // Any status but `notDownloaded` is the host answering our request. + if (setup.download !== 'notDownloaded') { + this._pendingRequests.delete(setup.agent); + } + } + this._applyConsent(); + this._onDidChangeSetups.fire(setups); + } + + private _readConsentedAgents(): ReadonlySet { + return readConsentedSdkAgents(this._storageService.get(AGENT_SDK_DOWNLOAD_CONSENT_KEY, StorageScope.APPLICATION)); + } + + /** + * Honour standing consent without asking again. Runs on every status change + * because a host that starts (or a remote that connects) publishes + * `notDownloaded` only once it is up — there is no earlier moment to catch. + */ + private _applyConsent(): void { + for (const agent of resolveConsentedSdkDownloads(this._readConsentedAgents(), this._setups, this._consentedRequests)) { + this._consentedRequests.add(agent); + this._reportStep(agent, 'consentedDownload'); + this._dispatchDownloadRequest(agent); + } + } +} + +registerSingleton(IAgentSdkSetupService, AgentSdkSetupService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/agentHost/browser/codexAccountService.ts b/src/vs/workbench/services/agentHost/browser/codexAccountService.ts index 390ce39654a52..fbb2f8fd8a201 100644 --- a/src/vs/workbench/services/agentHost/browser/codexAccountService.ts +++ b/src/vs/workbench/services/agentHost/browser/codexAccountService.ts @@ -9,6 +9,7 @@ import { Action, IAction, SubmenuAction, toAction } from '../../../../base/commo import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, ICodexAccountInfo, readCodexAccountInfo } from '../../../../platform/agentHost/common/codexAccount.js'; +import { CODEX_AGENT_PROVIDER_ID } from '../../../../platform/agentHost/common/agent.js'; import { AgentHostCodexAgentEnabledSettingId, CodexPreferAgentHostEditorSettingId, IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; import { ChatAIDisabledSettingId } from '../../../../platform/chat/common/chatSettings.js'; import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js'; @@ -25,6 +26,12 @@ export const ICodexAccountService = createDecorator('codex export interface ICodexAccountService { readonly _serviceBrand: undefined; + /** + * The agent whose account this service manages, so callers that dispatch by + * agent id — the SDK setup banner's Sign In button — can check they are + * talking to the right service without carrying a literal `'codex'`. + */ + readonly agent: string; readonly account: ICodexAccountInfo; readonly onDidChangeAccount: Event; signIn(): void; @@ -73,6 +80,8 @@ export function openCodexAuthUrl(openerService: Pick, au class CodexAccountService extends Disposable implements ICodexAccountService { declare readonly _serviceBrand: undefined; + readonly agent = CODEX_AGENT_PROVIDER_ID; + private readonly _onDidChangeAccount = this._register(new Emitter()); readonly onDidChangeAccount = this._onDidChangeAccount.event; diff --git a/src/vs/workbench/services/agentHost/test/browser/codexAccountService.test.ts b/src/vs/workbench/services/agentHost/test/browser/codexAccountService.test.ts index aec3277eaa3c5..5731dd4d83583 100644 --- a/src/vs/workbench/services/agentHost/test/browser/codexAccountService.test.ts +++ b/src/vs/workbench/services/agentHost/test/browser/codexAccountService.test.ts @@ -8,6 +8,7 @@ import { Action, SubmenuAction } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { AgentHostCodexAgentEnabledSettingId, CodexPreferAgentHostEditorSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { CODEX_AGENT_PROVIDER_ID } from '../../../../../platform/agentHost/common/agent.js'; import { ChatAIDisabledSettingId } from '../../../../../platform/chat/common/chatSettings.js'; import { OpenOptions } from '../../../../../platform/opener/common/opener.js'; import { ICodexAccountService, createCodexAccountMenuActions, hasSignedInCodexChatGPTAccount, openCodexAuthUrl, shouldShowCodexAccount } from '../../browser/codexAccountService.js'; @@ -18,6 +19,7 @@ suite('CodexAccountService', () => { function service(status: ICodexAccountService['account']['status'], email?: string): ICodexAccountService & { signInCalls: number; signOutCalls: number } { return { _serviceBrand: undefined, + agent: CODEX_AGENT_PROVIDER_ID, account: { status, email }, onDidChangeAccount: Event.None, signInCalls: 0, From c14e0dc56600cab2b5d897c5752ea10118ade3b3 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 20 Aug 2026 17:37:36 +0100 Subject: [PATCH 09/29] Agents window: adopt Modern UI notification and dialog styles (#331808) * modernUI: enhance notification and dialog presentation with customizable insets * sessions: implement dynamic notification row height for phone layouts * modernUI: center notification content in touch-sized phone rows and adjust notification positioning --------- Co-authored-by: mrleemurray --- .../lib/stylelint/vscode-known-variables.json | 3 + src/vs/sessions/browser/media/phoneLayout.css | 13 ++- src/vs/sessions/browser/media/workbench.css | 25 +----- src/vs/sessions/browser/workbench.ts | 61 +++---------- src/vs/sessions/sessions.common.main.ts | 1 + .../sessions/test/browser/workbench.test.ts | 56 ++++++++++++ .../notifications/notificationsViewer.ts | 6 +- .../browser/media/notificationsDialogs.css | 89 ++++++++++++------- .../modernUI/browser/media/roundedCorners.css | 30 ------- .../modernUI/browser/modernUI.contribution.ts | 29 +++--- .../browser/modernUI.contribution.test.ts | 82 +++++++++++++++++ 11 files changed, 245 insertions(+), 150 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 7859d45cef764..593a6366dbd20 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1086,6 +1086,9 @@ "--modern-ui-editor-tab-unfocused-hover-foreground", "--modern-ui-editor-tab-unfocused-inactive-background", "--modern-ui-editor-tab-unfocused-inactive-foreground", + "--modern-ui-notifications-block-end-inset", + "--modern-ui-notifications-block-start-inset", + "--modern-ui-notifications-inline-inset", "--modern-ui-shell-background", "--modern-ui-tab-active-background", "--modern-ui-tab-hover-background", diff --git a/src/vs/sessions/browser/media/phoneLayout.css b/src/vs/sessions/browser/media/phoneLayout.css index b3ca9af8a561f..add028b8782d5 100644 --- a/src/vs/sessions/browser/media/phoneLayout.css +++ b/src/vs/sessions/browser/media/phoneLayout.css @@ -58,6 +58,7 @@ .agent-sessions-workbench.phone-layout .monaco-dialog-box { width: calc(100% - 32px); + min-width: 0; max-width: calc(100% - 32px); } @@ -69,6 +70,7 @@ /* ---- Phone Layout: Notifications and Hovers ---- */ .agent-sessions-workbench.phone-layout .notifications-toasts { + --modern-ui-notifications-block-start-inset: calc(env(safe-area-inset-top) + 48px + var(--vscode-spacing-size40)); left: 8px !important; right: 8px !important; bottom: auto !important; @@ -81,7 +83,16 @@ max-width: 100%; } -.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { +.agent-sessions-workbench.phone-layout .notifications-list-container .notification-list-item > .notification-list-item-main-row { + align-items: center; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts, +.agent-sessions-workbench.phone-layout .notifications-toasts .notifications-list-container, +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast-container > .notification-toast, +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast-container > .notification-toast .monaco-list:not(.element-focused):focus::before, +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast-container > .notification-toast .monaco-list-row { border-radius: var(--vscode-cornerRadius-xLarge); } diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index dd8dc3e3eea14..7af1ca383140f 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -10,6 +10,9 @@ .monaco-workbench.agent-sessions-workbench { background-color: var(--vscode-agents-background); --model-hover-surface-background: var(--vscode-agentsPanel-background); + --modern-ui-notifications-inline-inset: var(--vscode-spacing-size80); + --modern-ui-notifications-block-end-inset: var(--vscode-spacing-size80); + --modern-ui-notifications-block-start-inset: var(--vscode-spacing-size400); } /* ---- Workbench Shell ---- */ @@ -84,12 +87,6 @@ display: none; } -.monaco-workbench.agent-sessions-workbench > .notifications-center, -.monaco-workbench.agent-sessions-workbench > .notifications-toasts { - right: 15px; - bottom: 15px; -} - .monaco-workbench.agent-sessions-workbench .monaco-dialog-modal-block.sessions-signing-in-dialog-modal-block { z-index: 2570; /* below sign-in notifications and Quick Input (2571), and normal dialogs (2575) */ } @@ -99,22 +96,6 @@ z-index: 2571; /* above the sign-in dialog (2570), below normal dialogs (2575) */ } -.monaco-workbench.agent-sessions-workbench.nostatusbar > .notifications-center, -.monaco-workbench.agent-sessions-workbench.nostatusbar > .notifications-toasts { - bottom: 15px; -} - -.monaco-workbench.agent-sessions-workbench > .notifications-center.bottom-left, -.monaco-workbench.agent-sessions-workbench > .notifications-toasts.bottom-left { - right: auto; - left: 15px; -} - -.monaco-workbench.agent-sessions-workbench > .notifications-center.top-right { - top: 40px; - bottom: auto; -} - .agent-sessions-workbench.shell-gradient-background .part.titlebar, .agent-sessions-workbench.shell-gradient-background .part.sidebar, .agent-sessions-workbench.shell-gradient-background .part.titlebar > .content, diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b7cefca90e5fe..da01e482d48a1 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -9,7 +9,7 @@ import './media/workbench.css'; import './media/phoneLayout.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { addDisposableGenericMouseDownListener, addDisposableListener, EventType, getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { addDisposableGenericMouseDownListener, addDisposableListener, EventType, getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; @@ -61,6 +61,7 @@ import { NotificationsStatus } from '../../workbench/browser/parts/notifications import { registerNotificationCommands } from '../../workbench/browser/parts/notifications/notificationsCommands.js'; import { CommandsRegistry } from '../../platform/commands/common/commands.js'; import { NotificationsToasts } from '../../workbench/browser/parts/notifications/notificationsToasts.js'; +import { COMPACT_NOTIFICATION_ROW_HEIGHT, DEFAULT_NOTIFICATION_ROW_HEIGHT, setNotificationRowHeight } from '../../workbench/browser/parts/notifications/notificationsViewer.js'; import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; @@ -68,11 +69,6 @@ import { TitleService } from './parts/titlebarPart.js'; import { EDITOR_PART_DEFAULT_WIDTH, EDITOR_PART_MINIMUM_WIDTH } from './parts/editorPartSizing.js'; import { IContextKey, IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; import { CustomViewVisibleContext, EditorMaximizedContext, IsPhoneLayoutContext, SinglePaneLayoutEnabledContext } from '../common/contextkeys.js'; -import { - NotificationsPosition, - NotificationsSettings, - getNotificationsPosition -} from '../../workbench/common/notifications.js'; import { SessionsLayoutPolicy } from './layoutPolicy.js'; import { AGENTS_PART_CARD_CLASS } from './parts/agentsPartCard.js'; import { MobileNavigationStack } from './mobileNavigationStack.js'; @@ -87,6 +83,8 @@ import { ICustomViewDescriptor } from '../services/customView/browser/customView import { ISessionsSetUpService } from './sessionsSetUpService.js'; import { AGENTS_FLOATING_PANEL_GAP } from '../common/layoutConstants.js'; +const PHONE_NOTIFICATION_ROW_HEIGHT = 44; + //#region Workbench Options export interface IWorkbenchOptions { @@ -102,6 +100,7 @@ export interface IWorkbenchOptions { enum LayoutClasses { MODERN_UI_TABS = 'modern-ui-tabs', + MODERN_UI_NOTIFICATIONS_DIALOGS = 'modern-ui-notifications-dialogs', SIDEBAR_HIDDEN = 'nosidebar', MAIN_EDITOR_AREA_HIDDEN = 'nomaineditorarea', PANEL_HIDDEN = 'nopanel', @@ -905,6 +904,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic 'monaco-workbench', 'agent-sessions-workbench', LayoutClasses.MODERN_UI_TABS, + LayoutClasses.MODERN_UI_NOTIFICATIONS_DIALOGS, // LayoutClasses.SHELL_GRADIENT_BACKGROUND, platformClass, isWeb ? 'web' : undefined, @@ -945,7 +945,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.createCustomViewGridPart(); // Notification Handlers - this.createNotificationsHandlers(instantiationService, notificationService, configurationService); + this.createNotificationsHandlers(instantiationService, notificationService); // Add Workbench to DOM this.parent.appendChild(this.mainContainer); @@ -1010,9 +1010,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private createNotificationsHandlers( instantiationService: IInstantiationService, - notificationService: NotificationService, - configurationService: IConfigurationService + notificationService: NotificationService ): void { + this.registerNotificationRowHeight(); + // Instantiate Notification components const notificationsCenter = this._register(instantiationService.createInstance(NotificationsCenter, this.mainContainer, notificationService.model)); const notificationsToasts = this._register(instantiationService.createInstance(NotificationsToasts, this.mainContainer, notificationService.model)); @@ -1035,11 +1036,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Register notification accessible view AccessibleViewRegistry.register(new NotificationAccessibleView()); - // The shared notification controllers apply a top-right inline offset based on the - // default workbench custom titlebar height. The sessions workbench has its own - // fixed chrome, so re-apply the sessions-specific top-right offset after they run. - this.registerSessionsNotificationOffsets(configurationService, notificationsCenter, notificationsToasts); - // Register with Layout this.registerNotifications({ onDidChangeNotificationsVisibility: Event.map( @@ -1049,40 +1045,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic }); } - private registerSessionsNotificationOffsets( - configurationService: IConfigurationService, - notificationsCenter: NotificationsCenter, - notificationsToasts: NotificationsToasts - ): void { - const applySessionsNotificationOffsets = () => { - const position = getNotificationsPosition(configurationService); - const notificationsCenterContainer = this.getWorkbenchChildByClassName('notifications-center'); - const notificationsToastsContainer = this.getWorkbenchChildByClassName('notifications-toasts'); - - if (position === NotificationsPosition.TOP_RIGHT) { - notificationsCenterContainer?.style.setProperty('top', '40px'); - notificationsToastsContainer?.style.setProperty('top', '40px'); - } - }; - - this._register(this.onDidLayoutMainContainer(() => applySessionsNotificationOffsets())); - this._register(notificationsCenter.onDidChangeVisibility(() => applySessionsNotificationOffsets())); - this._register(notificationsToasts.onDidChangeVisibility(() => applySessionsNotificationOffsets())); - this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { - applySessionsNotificationOffsets(); - } + private registerNotificationRowHeight(): void { + this._register(autorun(reader => { + setNotificationRowHeight(this.layoutPolicy.isPhoneLayout.read(reader) ? PHONE_NOTIFICATION_ROW_HEIGHT : COMPACT_NOTIFICATION_ROW_HEIGHT); })); - } - - private getWorkbenchChildByClassName(className: string): HTMLElement | undefined { - for (const child of this.mainContainer.children) { - if (isHTMLElement(child) && child.classList.contains(className)) { - return child; - } - } - - return undefined; + this._register(toDisposable(() => setNotificationRowHeight(DEFAULT_NOTIFICATION_ROW_HEIGHT))); } private createPartContainer(id: string, role: string, classes: string[]): HTMLElement { diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 3ee3a41457de0..d7d5a085f3322 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -12,6 +12,7 @@ import { TERMINAL_BACKGROUND_COLOR } from '../workbench/contrib/terminal/common/ import '../workbench/api/browser/extensionHost.contribution.js'; import '../workbench/browser/workbench.contribution.js'; +import '../workbench/contrib/modernUI/browser/media/notificationsDialogs.css'; import { agentsPanelBackground } from './common/theme.js'; import './common/sizes.js'; diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 1da1f2fb5bcc7..1d86c3ad5d255 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -4,7 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../base/common/observable.js'; import { SashState } from '../../../base/browser/ui/sash/sash.js'; +import { mainWindow } from '../../../base/browser/window.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { Part } from '../../../workbench/browser/part.js'; import { IPartVisibilityChangeEvent, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; @@ -16,6 +19,7 @@ import { DockedEditorInput } from '../../common/dockedEditorInput.js'; import { EditorInputCapabilities } from '../../../workbench/common/editor.js'; import { SESSIONS_LIST_MINIMUM_WIDTH } from '../../browser/parts/sidebarPart.js'; import { Menus } from '../../browser/menus.js'; +import { DEFAULT_NOTIFICATION_ROW_HEIGHT, onDidChangeNotificationRowHeight, setNotificationRowHeight } from '../../../workbench/browser/parts/notifications/notificationsViewer.js'; interface IViewSize { width: number; height: number } @@ -69,6 +73,10 @@ suite('Sessions - Workbench', () => { const restoreEditorPartOnActivation = Reflect.get(Workbench.prototype, '_restoreEditorPartOnActivation') as (this: ITestWorkbench) => void; const layoutSinglePaneGrid = Reflect.get(SinglePaneWorkbench.prototype, '_layoutGrid') as (this: IContainerResizeTestHarness) => void; const preserveSessionsEditorRatio = Reflect.get(SinglePaneWorkbench.prototype, '_preserveSessionsEditorRatio') as (this: IProportionalResizeTestHarness, previousSessionsWidth: number, previousEditorWidth: number) => void; + const registerNotificationRowHeight = Reflect.get(Workbench.prototype, 'registerNotificationRowHeight') as (this: { + layoutPolicy: { isPhoneLayout: IObservable }; + _register(disposable: T): T; + }) => void; // --- Harness ------------------------------------------------------------ @@ -347,6 +355,54 @@ suite('Sessions - Workbench', () => { } } + // --- Notifications ------------------------------------------------------ + + test('uses touch-sized notification rows on phone layouts', () => { + setNotificationRowHeight(DEFAULT_NOTIFICATION_ROW_HEIGHT); + const registeredDisposables = new DisposableStore(); + const isPhoneLayout = observableValue('isPhoneLayout', false); + const rowHeights: number[] = []; + const listener = onDidChangeNotificationRowHeight(height => rowHeights.push(height)); + + try { + registerNotificationRowHeight.call({ + layoutPolicy: { isPhoneLayout }, + _register: disposable => registeredDisposables.add(disposable), + }); + + isPhoneLayout.set(true, undefined); + isPhoneLayout.set(false, undefined); + registeredDisposables.dispose(); + + assert.deepStrictEqual(rowHeights, [34, 44, 34, 42]); + } finally { + listener.dispose(); + registeredDisposables.dispose(); + setNotificationRowHeight(DEFAULT_NOTIFICATION_ROW_HEIGHT); + } + }); + + test('centers notification content in touch-sized phone rows', () => { + const root = document.createElement('div'); + root.className = 'agent-sessions-workbench phone-layout'; + const list = document.createElement('div'); + list.className = 'notifications-list-container'; + const item = document.createElement('div'); + item.className = 'notification-list-item'; + const mainRow = document.createElement('div'); + mainRow.className = 'notification-list-item-main-row'; + item.appendChild(mainRow); + list.appendChild(item); + root.appendChild(list); + document.body.appendChild(root); + + try { + assert.strictEqual(mainWindow.getComputedStyle(mainRow).alignItems, 'center'); + } finally { + root.remove(); + } + }); + // --- Editor split / reveal --------------------------------------------- test('activating a minimized Sessions or Editor Part resizes its sibling to minimum width', () => { diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 082caa715ca57..a760121da4bf5 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -37,14 +37,16 @@ import { IConfigurationService } from '../../../../platform/configuration/common /** Default height (px) of a single notification row. */ export const DEFAULT_NOTIFICATION_ROW_HEIGHT = 42; +/** Compact height (px) of a single notification row. */ +export const COMPACT_NOTIFICATION_ROW_HEIGHT = 34; + /** Current height (px) of a single notification row; overridable via {@link setNotificationRowHeight}. */ let notificationRowHeight = DEFAULT_NOTIFICATION_ROW_HEIGHT; const onDidChangeNotificationRowHeightEmitter = new Emitter(); export const onDidChangeNotificationRowHeight = onDidChangeNotificationRowHeightEmitter.event; /** - * Overrides the height (px) of a single notification row. Used by the Modern UI - * Modern UI experiment to shrink the collapsed notification card. + * Overrides the height (px) of a single notification row. */ export function setNotificationRowHeight(height: number): void { if (height !== notificationRowHeight) { diff --git a/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css b/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css index 6d9d3804e9eaf..dafd2d8db21d5 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css @@ -6,23 +6,29 @@ /* * Modern UI module: "Notifications and Dialogs". * + * Shared notification and dialog presentation used by the regular workbench + * and the Agents window. Shell-specific notification insets are customizable + * through the properties used below. + * + * All rules are gated behind the module-specific + * `.modern-ui-notifications-dialogs` class. */ -.modern-ui.monaco-workbench .notifications-list-container .notification-list-item { +.modern-ui-notifications-dialogs.monaco-workbench .notifications-list-container .notification-list-item { padding: var(--vscode-spacing-size60) var(--vscode-spacing-size20); } -.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-icon { +.modern-ui-notifications-dialogs.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-icon { font-size: var(--vscode-codiconFontSize); margin: 0 var(--vscode-spacing-size80) 0 var(--vscode-spacing-size60); } -.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button-dropdown, -.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button { +.modern-ui-notifications-dialogs.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button-dropdown, +.modern-ui-notifications-dialogs.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button { margin: 0 var(--vscode-spacing-size40) 0 0; } -.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-source { +.modern-ui-notifications-dialogs.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-source { color: var(--vscode-descriptionForeground); margin-left: var(--vscode-spacing-size240); } @@ -38,20 +44,25 @@ * (DEFAULT_CUSTOM_TITLEBAR_HEIGHT), which the Modern UI experiment always shows * — so the center tucks directly beneath it. */ -.modern-ui.monaco-workbench > .notifications-center { - right: var(--vscode-spacing-size80); - bottom: var(--vscode-spacing-size320); +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-center { + right: var(--modern-ui-notifications-inline-inset, var(--vscode-spacing-size80)); + bottom: var(--modern-ui-notifications-block-end-inset, var(--vscode-spacing-size320)); + border-radius: var(--vscode-cornerRadius-large); +} + +.modern-ui-notifications-dialogs.monaco-workbench.nostatusbar > .notifications-center:not(.top-right) { + bottom: var(--modern-ui-notifications-block-end-inset, var(--vscode-spacing-size320)); } -.modern-ui.monaco-workbench > .notifications-center.bottom-left { +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-center.bottom-left { right: auto; - left: var(--vscode-spacing-size80); - bottom: var(--vscode-spacing-size320); + left: var(--modern-ui-notifications-inline-inset, var(--vscode-spacing-size80)); + bottom: var(--modern-ui-notifications-block-end-inset, var(--vscode-spacing-size320)); } -.modern-ui.monaco-workbench > .notifications-center.top-right { +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-center.top-right { bottom: auto; - top: var(--vscode-spacing-size360) !important; + top: var(--modern-ui-notifications-block-start-inset, var(--vscode-spacing-size360)) !important; } /* @@ -59,61 +70,77 @@ * container offset is reduced by 4px to keep the visible toast surface aligned * with the center (8px / 32px / 35px once the margin is added back). */ -.modern-ui.monaco-workbench > .notifications-toasts { - right: var(--vscode-spacing-size40); - bottom: var(--vscode-spacing-size280); +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts { + right: calc(var(--modern-ui-notifications-inline-inset, var(--vscode-spacing-size80)) - var(--vscode-spacing-size40)); + bottom: calc(var(--modern-ui-notifications-block-end-inset, var(--vscode-spacing-size320)) - var(--vscode-spacing-size40)); +} + +.modern-ui-notifications-dialogs.monaco-workbench.nostatusbar > .notifications-toasts:not(.top-right) { + bottom: calc(var(--modern-ui-notifications-block-end-inset, var(--vscode-spacing-size320)) - var(--vscode-spacing-size40)); } -.modern-ui.monaco-workbench > .notifications-toasts.bottom-left { +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts.bottom-left { right: auto; - left: var(--vscode-spacing-size40); - bottom: var(--vscode-spacing-size280); + left: calc(var(--modern-ui-notifications-inline-inset, var(--vscode-spacing-size80)) - var(--vscode-spacing-size40)); + bottom: calc(var(--modern-ui-notifications-block-end-inset, var(--vscode-spacing-size320)) - var(--vscode-spacing-size40)); } -.modern-ui.monaco-workbench > .notifications-toasts.top-right { +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts.top-right { bottom: auto; - top: var(--vscode-spacing-size320) !important; + top: calc(var(--modern-ui-notifications-block-start-inset, var(--vscode-spacing-size360)) - var(--vscode-spacing-size40)) !important; } -.modern-ui.monaco-workbench > .notifications-center > .notifications-center-header { +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-center > .notifications-center-header { padding-right: var(--vscode-spacing-size20); } -.modern-ui .monaco-dialog-box { +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-center .notifications-list-container .monaco-list-row:last-child { + border-radius: 0 0 var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large); +} + +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts, +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts .notifications-list-container, +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast, +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list:not(.element-focused):focus::before, +.modern-ui-notifications-dialogs.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list-row { + border-radius: var(--vscode-cornerRadius-large); +} + +.modern-ui-notifications-dialogs .monaco-dialog-box { padding: var(--vscode-spacing-size40); min-width: 440px; } -.modern-ui .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { +.modern-ui-notifications-dialogs .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { padding-left: var(--vscode-spacing-size80); padding-right: var(--vscode-spacing-size200); } -.modern-ui .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { +.modern-ui-notifications-dialogs .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { margin: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size120) 0; font-size: var(--vscode-fontSize-heading3); font-weight: var(--vscode-fontWeight-semiBold); } -.modern-ui .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { +.modern-ui-notifications-dialogs .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { color: var(--vscode-descriptionForeground); } -.modern-ui .monaco-dialog-box .dialog-toolbar-row { +.modern-ui-notifications-dialogs .monaco-dialog-box .dialog-toolbar-row { position: absolute; top: var(--vscode-spacing-size80); right: var(--vscode-spacing-size80); } -.modern-ui .monaco-dialog-box .dialog-footer-row { +.modern-ui-notifications-dialogs .monaco-dialog-box .dialog-footer-row { padding: 0 var(--vscode-spacing-size80); } -.modern-ui .monaco-dialog-box .dialog-message-row { +.modern-ui-notifications-dialogs .monaco-dialog-box .dialog-message-row { padding: var(--vscode-spacing-size160) var(--vscode-spacing-size80) 0; } -.modern-ui .monaco-dialog-box > .dialog-buttons-row { +.modern-ui-notifications-dialogs .monaco-dialog-box > .dialog-buttons-row { padding: var(--vscode-spacing-size160) 0 0; } - diff --git a/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css b/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css index d0c11dceb720c..61a5f5406dc7b 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css @@ -215,36 +215,6 @@ border-radius: var(--vscode-cornerRadius-large); } -/* - * Notification center (floats above the status bar). It already clips its - * children with `overflow: hidden`. Match the final row's bottom corners to the - * container so its focused and hover backgrounds follow the outer surface. - */ -.modern-ui.monaco-workbench > .notifications-center { - border-radius: var(--vscode-cornerRadius-large); -} - -.modern-ui.monaco-workbench > .notifications-center .notifications-list-container .monaco-list-row:last-child { - border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large); -} - -/* - * Notification toasts. The visible, shadowed surface is the nested - * `.notifications-list-container`, wrapped by `.notification-toast` (and its - * scrollable element / list row). The base styles round every one of these - * layers to the same radius; round them together here too, otherwise a larger - * radius on the outer wrapper leaves a gap at the corner where the smaller - * inner radius shows through. Matching the base structure also gives these - * rules enough specificity to replace its control-tier radius. - */ -.modern-ui.monaco-workbench > .notifications-toasts .notifications-list-container, -.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast, -.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, -.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list:not(.element-focused):focus:before, -.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list-row { - border-radius: var(--vscode-cornerRadius-large); -} - /* Modal dialogs */ .modern-ui .monaco-dialog-box { border-radius: var(--vscode-cornerRadius-large); diff --git a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts index 8dbe2f2c4aba1..ee88b721c3e12 100644 --- a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts +++ b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts @@ -8,21 +8,17 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IWorkbenchLayoutService, LayoutSettings } from '../../../services/layout/browser/layoutService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { DEFAULT_SCROLLBAR_SIZE, setGlobalDefaultScrollbarSize } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { DEFAULT_NOTIFICATION_ROW_HEIGHT, setNotificationRowHeight } from '../../../browser/parts/notifications/notificationsViewer.js'; +import { COMPACT_NOTIFICATION_ROW_HEIGHT, DEFAULT_NOTIFICATION_ROW_HEIGHT, setNotificationRowHeight } from '../../../browser/parts/notifications/notificationsViewer.js'; import { DEFAULT_PANE_HEADER_SIZE, setGlobalPaneHeaderSize } from '../../../../base/browser/ui/splitview/paneview.js'; /** Reduced scrollbar size (px) applied when Modern UI is on. */ const MODERN_UI_SCROLLBAR_SIZE = 8; -/** Reduced collapsed notification row height (px) applied when Modern UI is on. */ -const MODERN_UI_NOTIFICATION_ROW_HEIGHT = 34; - /** Increased pane header size (px) applied when Modern UI is on. */ const MODERN_UI_PANE_HEADER_SIZE = 28; -// Bundle the CSS for every Modern UI module. Every file gates all of its -// rules behind the single `.modern-ui` ancestor class, so the styles are -// inert until that class is toggled onto the workbench container(s) below. +// Bundle the CSS for every Modern UI module. Styles remain inert until their +// corresponding classes are toggled onto the workbench container(s) below. import './media/activityBar.css'; import './media/commandCenter.css'; import './media/editorBorder.css'; @@ -49,22 +45,19 @@ interface IModernUIModule { } /** - * The single class toggled onto the workbench container(s) when the Modern UI - * Update experiment is enabled. Every Modern UI module's CSS is gated - * behind this class (`.modern-ui ...`), so all modules are applied together - * as a group. + * The primary class toggled when the Modern UI experiment is enabled. Modules + * that can be reused independently also receive dedicated classes below. */ const MODERN_UI_CLASS = 'modern-ui'; const MODERN_UI_TABS_CLASS = 'modern-ui-tabs'; +const MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS = 'modern-ui-notifications-dialogs'; const MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS = 'modern-ui-uppercase-view-headers'; /** * The fixed catalog of built-in Modern UI modules. The CSS for each module - * ships with the product (imported above) and is gated behind the shared - * `.modern-ui` class. All modules are enabled together as part of the - * Modern UI Update experiment (`LayoutSettings.MODERN_UI`). This catalog is - * retained to track per-module metadata (e.g. whether a module is - * layout-affecting). + * ships with the product (imported above), and all modules are enabled together + * as part of the Modern UI experiment (`LayoutSettings.MODERN_UI`). This catalog + * tracks per-module metadata such as whether a module affects layout. */ const MODERN_UI_MODULES: readonly IModernUIModule[] = [ { id: 'activityBar' }, @@ -158,6 +151,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri private applyTo(container: HTMLElement, enabled: boolean, useUppercaseViewHeaders: boolean): void { container.classList.toggle(MODERN_UI_CLASS, enabled); container.classList.toggle(MODERN_UI_TABS_CLASS, enabled); + container.classList.toggle(MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS, enabled); container.classList.toggle(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS, useUppercaseViewHeaders); } @@ -166,7 +160,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri } private applyNotificationRowHeight(enabled: boolean): void { - setNotificationRowHeight(enabled ? MODERN_UI_NOTIFICATION_ROW_HEIGHT : DEFAULT_NOTIFICATION_ROW_HEIGHT); + setNotificationRowHeight(enabled ? COMPACT_NOTIFICATION_ROW_HEIGHT : DEFAULT_NOTIFICATION_ROW_HEIGHT); } private applyPaneHeaderSize(enabled: boolean): void { @@ -178,6 +172,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri for (const container of this.layoutService.containers) { container.classList.remove(MODERN_UI_CLASS); container.classList.remove(MODERN_UI_TABS_CLASS); + container.classList.remove(MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS); container.classList.remove(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS); } setGlobalDefaultScrollbarSize(DEFAULT_SCROLLBAR_SIZE); diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index c756ba0ea1926..632037c40fb09 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -29,6 +29,8 @@ import { generateColorThemeCSS } from '../../../../services/themes/browser/color import '../../../../browser/parts/activitybar/media/activityaction.css'; import '../../../../browser/parts/media/paneCompositePart.css'; import { ModernUIContribution } from '../../browser/modernUI.contribution.js'; +import '../../../../browser/parts/notifications/media/notificationsCenter.css'; +import '../../../../browser/parts/notifications/media/notificationsToasts.css'; class ModernUITestPane extends Pane { @@ -121,9 +123,11 @@ suite('ModernUIContribution', () => { const startupState = { mainEnabled: layoutService.mainContainer.classList.contains('modern-ui'), mainTabsEnabled: layoutService.mainContainer.classList.contains('modern-ui-tabs'), + mainNotificationsDialogsEnabled: layoutService.mainContainer.classList.contains('modern-ui-notifications-dialogs'), mainUppercaseViewHeaders: layoutService.mainContainer.classList.contains('modern-ui-uppercase-view-headers'), auxiliaryEnabled: auxiliaryContainer.classList.contains('modern-ui'), auxiliaryTabsEnabled: auxiliaryContainer.classList.contains('modern-ui-tabs'), + auxiliaryNotificationsDialogsEnabled: auxiliaryContainer.classList.contains('modern-ui-notifications-dialogs'), auxiliaryUppercaseViewHeaders: auxiliaryContainer.classList.contains('modern-ui-uppercase-view-headers'), paneHeaderSize: pane.minimumSize, paneHeaderLineHeight: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, @@ -143,9 +147,11 @@ suite('ModernUIContribution', () => { startupState, mainEnabledAfterToggle: layoutService.mainContainer.classList.contains('modern-ui'), mainTabsEnabledAfterToggle: layoutService.mainContainer.classList.contains('modern-ui-tabs'), + mainNotificationsDialogsEnabledAfterToggle: layoutService.mainContainer.classList.contains('modern-ui-notifications-dialogs'), mainUppercaseViewHeadersAfterToggle: layoutService.mainContainer.classList.contains('modern-ui-uppercase-view-headers'), auxiliaryEnabledAfterToggle: auxiliaryContainer.classList.contains('modern-ui'), auxiliaryTabsEnabledAfterToggle: auxiliaryContainer.classList.contains('modern-ui-tabs'), + auxiliaryNotificationsDialogsEnabledAfterToggle: auxiliaryContainer.classList.contains('modern-ui-notifications-dialogs'), auxiliaryUppercaseViewHeadersAfterToggle: auxiliaryContainer.classList.contains('modern-ui-uppercase-view-headers'), paneHeaderSizeAfterToggle: pane.minimumSize, paneHeaderLineHeightAfterToggle: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, @@ -155,9 +161,11 @@ suite('ModernUIContribution', () => { startupState: { mainEnabled: true, mainTabsEnabled: true, + mainNotificationsDialogsEnabled: true, mainUppercaseViewHeaders: true, auxiliaryEnabled: true, auxiliaryTabsEnabled: true, + auxiliaryNotificationsDialogsEnabled: true, auxiliaryUppercaseViewHeaders: true, paneHeaderSize: 28, paneHeaderLineHeight: '28px', @@ -166,9 +174,11 @@ suite('ModernUIContribution', () => { }, mainEnabledAfterToggle: false, mainTabsEnabledAfterToggle: false, + mainNotificationsDialogsEnabledAfterToggle: false, mainUppercaseViewHeadersAfterToggle: false, auxiliaryEnabledAfterToggle: false, auxiliaryTabsEnabledAfterToggle: false, + auxiliaryNotificationsDialogsEnabledAfterToggle: false, auxiliaryUppercaseViewHeadersAfterToggle: false, paneHeaderSizeAfterToggle: 22, paneHeaderLineHeightAfterToggle: '22px', @@ -177,6 +187,78 @@ suite('ModernUIContribution', () => { }); }); + test('supports isolated notification and dialog presentation', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-notifications-dialogs nostatusbar'; + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-spacing-size60', '6px'); + root.style.setProperty('--vscode-spacing-size80', '8px'); + root.style.setProperty('--vscode-cornerRadius-large', '8px'); + root.style.setProperty('--modern-ui-notifications-inline-inset', '12px'); + root.style.setProperty('--modern-ui-notifications-block-end-inset', '20px'); + root.style.setProperty('--modern-ui-notifications-block-start-inset', '24px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const notificationList = appendElement(root, 'notifications-list-container'); + const notification = appendElement(notificationList, 'notification-list-item'); + const notificationsCenter = appendElement(root, 'notifications-center'); + const centerList = appendElement(notificationsCenter, 'notifications-list-container'); + const centerRow = appendElement(centerList, 'monaco-list-row'); + const topNotificationsCenter = appendElement(root, 'notifications-center top-right'); + const notificationsToasts = appendElement(root, 'notifications-toasts'); + const topNotificationsToasts = appendElement(root, 'notifications-toasts top-right'); + const toastContainer = appendElement(notificationsToasts, 'notification-toast-container'); + const toast = appendElement(toastContainer, 'notification-toast'); + const toastList = appendElement(toast, 'notifications-list-container'); + const toastRow = appendElement(toastList, 'monaco-list-row'); + const dialog = appendElement(root, 'monaco-dialog-box'); + + const targetWindow = getWindow(root); + const notificationStyle = targetWindow.getComputedStyle(notification); + const notificationsCenterStyle = targetWindow.getComputedStyle(notificationsCenter); + const centerRowStyle = targetWindow.getComputedStyle(centerRow); + const topNotificationsCenterStyle = targetWindow.getComputedStyle(topNotificationsCenter); + const notificationsToastsStyle = targetWindow.getComputedStyle(notificationsToasts); + const topNotificationsToastsStyle = targetWindow.getComputedStyle(topNotificationsToasts); + const toastStyle = targetWindow.getComputedStyle(toast); + const toastRowStyle = targetWindow.getComputedStyle(toastRow); + const dialogStyle = targetWindow.getComputedStyle(dialog); + + assert.deepStrictEqual({ + notificationPadding: notificationStyle.padding, + notificationsCenterRight: notificationsCenterStyle.right, + notificationsCenterBottom: notificationsCenterStyle.bottom, + notificationsCenterRadius: notificationsCenterStyle.borderRadius, + centerRowRadius: centerRowStyle.borderRadius, + topNotificationsCenterTop: topNotificationsCenterStyle.top, + notificationsToastsRight: notificationsToastsStyle.right, + notificationsToastsBottom: notificationsToastsStyle.bottom, + notificationsToastsRadius: notificationsToastsStyle.borderRadius, + topNotificationsToastsTop: topNotificationsToastsStyle.top, + toastRadius: toastStyle.borderRadius, + toastRowRadius: toastRowStyle.borderRadius, + dialogPadding: dialogStyle.padding, + dialogMinWidth: dialogStyle.minWidth, + }, { + notificationPadding: '6px 2px', + notificationsCenterRight: '12px', + notificationsCenterBottom: '20px', + notificationsCenterRadius: '8px', + centerRowRadius: '0px 0px 8px 8px', + topNotificationsCenterTop: '24px', + notificationsToastsRight: '8px', + notificationsToastsBottom: '16px', + notificationsToastsRadius: '8px', + topNotificationsToastsTop: '20px', + toastRadius: '8px', + toastRowRadius: '8px', + dialogPadding: '4px', + dialogMinWidth: '440px', + }); + }); + test('uses part-specific pane colors and only draws panel header separators in vertical layouts', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui'; From 32b97f54e2ec0341c32d665e1f6f136db29cfe79 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:37:48 +0000 Subject: [PATCH 10/29] Cap External Agent Sessions to 30 Days (replace `all`, enforce ingest/prune retention) (#331635) * Initial plan * Replace external session All mode with 30-day retention Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> * Limit ESLint worker concurrency Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> * Revert "Limit ESLint worker concurrency" This reverts commit 9190dc515dc124bdb60b19702f5d5bd6d6f6e5a0. Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- .../agentHost/common/agentHostSchema.ts | 2 +- .../platform/agentHost/node/agentService.ts | 81 ++++++++- .../agentHost/node/agentSessionRegistry.ts | 5 + .../agentHost/test/node/agentService.test.ts | 155 ++++++++++++------ .../sessionLifecycle.integrationTest.ts | 2 +- src/vs/platform/chat/common/chatSettings.ts | 2 +- .../chat/browser/externalSessionBanner.ts | 14 +- .../browser/externalSessionBanner.test.ts | 6 +- .../externalSessionsFilterMenu.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 12 +- .../externalSessionsFilterMenu.test.ts | 2 +- 11 files changed, 210 insertions(+), 73 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 10ec2d438d549..834ed14151cd8 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -818,7 +818,7 @@ export const platformRootSchema = createSchema({ type: 'string', title: localize('agentHost.config.showExternalSessions.title', "Show External Agent Sessions"), description: localize('agentHost.config.showExternalSessions.description', "Controls whether sessions created outside the Agent Host are included in the session catalog."), - enum: [ChatExternalSessionsMode.None, ChatExternalSessionsMode.Recent, ChatExternalSessionsMode.Last24Hours, ChatExternalSessionsMode.Last7Days, ChatExternalSessionsMode.All], + enum: [ChatExternalSessionsMode.None, ChatExternalSessionsMode.Recent, ChatExternalSessionsMode.Last24Hours, ChatExternalSessionsMode.Last7Days, ChatExternalSessionsMode.Last30Days], default: ChatExternalSessionsMode.None, }), [AgentHostCopilotMultiRootEnabledConfigKey]: schemaProperty({ diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 02cc9da6f8d40..984c6724e05a6 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -124,6 +124,8 @@ import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; */ const SESSION_GC_GRACE_MS = 30_000; const DAY_MS = 24 * 60 * 60 * 1000; +const EXTERNAL_SESSION_MAX_AGE_MS = 30 * DAY_MS; +const EXTERNAL_SESSION_PRUNE_DELAY_MS = 60_000; const RECENT_EXTERNAL_SESSION_LIMIT = 2; /** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */ const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000; @@ -841,6 +843,7 @@ export class AgentService extends Disposable implements IAgentService { session => this._agentMergeController.getTurnContext(session), ); this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor(), agentMergeTools, this._createArtifactServerToolAccessor())); + this._scheduleExternalSessionPrune(); } /** @@ -861,6 +864,54 @@ export class AgentService extends Disposable implements IAgentService { return this._sideEffects.onDidStartTurn; } + private _scheduleExternalSessionPrune(): void { + this._register(disposableTimeout(() => { + void this._pruneStaleExternalSessions().catch(error => { + this._logService.warn('[AgentService] Failed to prune stale external sessions', error); + }); + }, EXTERNAL_SESSION_PRUNE_DELAY_MS)); + } + + private async _pruneStaleExternalSessions(): Promise { + const now = this._now(); + const registered = await this._listRegisteredSessions(); + const staleExternalSessions: URI[] = []; + for (const entry of registered) { + if (!entry.external) { + continue; + } + const provider = this._providers.get(entry.provider); + if (!provider) { + continue; + } + let metadata: IAgentSessionMetadata | undefined; + try { + metadata = await this._registeredSessionMetadata(provider, entry.session, true); + } catch (error) { + this._logService.warn(`[AgentService] Failed to load metadata while pruning stale external session ${entry.session.toString()}`, error); + continue; + } + if (!metadata) { + continue; + } + if (readSessionEhcliAdoptable(metadata._meta)) { + continue; + } + if (this._isExternalSessionOlderThanMaxAge(metadata.modifiedTime, now)) { + staleExternalSessions.push(entry.session); + } + } + + for (const session of staleExternalSessions) { + await this._sessionRegistry.unregister(session); + } + if (staleExternalSessions.length > 0) { + this._invalidateSessionList(); + this._queueSessionListReconciliation(); + } + this._logService.info(`[AgentService] pruned ${staleExternalSessions.length} stale external session row(s) older than ${EXTERNAL_SESSION_MAX_AGE_MS / DAY_MS} days`); + } + // ---- provider registration ---------------------------------------------- /** @@ -1607,6 +1658,7 @@ export class AgentService extends Disposable implements IAgentService { const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); const discoveryLimiter = new Limiter(4); let suppressed = 0; + let skippedAsStale = 0; let registeredExternal = false; let alreadyRegistered = 0; let registryChanged = false; @@ -1624,6 +1676,10 @@ export class AgentService extends Disposable implements IAgentService { suppressed++; return false; } + if (external && !readSessionEhcliAdoptable(sessionMetadata._meta) && this._isExternalSessionOlderThanMaxAge(sessionMetadata.modifiedTime, this._now())) { + skippedAsStale++; + return false; + } const identity: IRegisteredSession = { session, provider: provider.id, startTime: metadata.startTime, external, source: external ? 'discovery' : 'restore' }; const registered = await this._retryRegistryMutation( () => this._sessionRegistry.register(session, identity, { checkTombstone: true }), @@ -1656,7 +1712,7 @@ export class AgentService extends Disposable implements IAgentService { if (registeredExternal) { this._queueSessionListReconciliation(); } - this._logService.info(`[AgentService] discovery for provider ${provider.id}: ${chats.length} candidate(s) (${chats.filter(chat => chat.external).length} external), ${registered} registered, ${alreadyRegistered} already registered, ${suppressed} suppressed as subagent/chat backing`); + this._logService.info(`[AgentService] discovery for provider ${provider.id}: ${chats.length} candidate(s) (${chats.filter(chat => chat.external).length} external), ${registered} registered, ${alreadyRegistered} already registered, ${suppressed} suppressed as subagent/chat backing, ${skippedAsStale} skipped as older than ${EXTERNAL_SESSION_MAX_AGE_MS / DAY_MS} days`); return registered > 0; } @@ -1689,10 +1745,13 @@ export class AgentService extends Disposable implements IAgentService { if (!identity) { continue; } + const metadata = sessions[index]; + if (identity.external && !readSessionEhcliAdoptable(metadata._meta) && this._isExternalSessionOlderThanMaxAge(metadata.modifiedTime, this._now())) { + continue; + } const registered = await this._sessionRegistry.register(identity.session, identity, { checkTombstone: true }); if (registered) { this._invalidateSessionList(); - const metadata = sessions[index]; if (identity.external && existing.get(identity.session.toString()) !== true) { await this._initializeExternalSessionReadState(identity.session); } @@ -2101,9 +2160,17 @@ export class AgentService extends Disposable implements IAgentService { } private _getExternalSessionsMode(): AgentHostExternalSessionsMode { + const rootValue = this._configurationService.getRootConfigValues()?.[AgentHostShowExternalSessionsConfigKey]; + if (rootValue === 'all') { + return AgentHostExternalSessionsMode.Last30Days; + } return this._configurationService.getRootValue(platformRootSchema, AgentHostShowExternalSessionsConfigKey) ?? AgentHostExternalSessionsMode.None; } + private _isExternalSessionOlderThanMaxAge(modifiedTime: number, now: number): boolean { + return modifiedTime < now - EXTERNAL_SESSION_MAX_AGE_MS; + } + private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number): ReadonlySet { const recentExternalSessions = sessions .filter(session => readSessionExternal(session._meta) @@ -2139,12 +2206,12 @@ export class AgentService extends Disposable implements IAgentService { case AgentHostExternalSessionsMode.Recent: return session.modifiedTime >= now - 7 * DAY_MS && (recentSessionKeys === undefined || recentSessionKeys.has(session.session.toString())); - case AgentHostExternalSessionsMode.All: - return true; case AgentHostExternalSessionsMode.Last24Hours: return session.modifiedTime >= now - DAY_MS; case AgentHostExternalSessionsMode.Last7Days: return session.modifiedTime >= now - 7 * DAY_MS; + case AgentHostExternalSessionsMode.Last30Days: + return !this._isExternalSessionOlderThanMaxAge(session.modifiedTime, now); case AgentHostExternalSessionsMode.None: return false; } @@ -2276,7 +2343,7 @@ export class AgentService extends Disposable implements IAgentService { previouslyExposed.add(session); } const listed = previousMode !== undefined - ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.All), previousMode, previouslyExposed) + ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.Last30Days), previousMode, previouslyExposed) : await this.listSessions(); const visible = new Set(); let published = 0; @@ -2326,7 +2393,7 @@ export class AgentService extends Disposable implements IAgentService { /** * Derives both the previous and current mode's visible sets from one catalog - * pass, since {@link AgentHostExternalSessionsMode.All} is a superset of every + * pass, since {@link AgentHostExternalSessionsMode.Last30Days} is a superset of every * mode and the mode is just a parameter to {@link _shouldIncludeSession}. * Adds what `previousMode` had exposed into `previouslyExposed`. */ @@ -2350,7 +2417,7 @@ export class AgentService extends Disposable implements IAgentService { const mode = this._getExternalSessionsMode(); const recentKeys = recentKeysFor(mode); const visible = superset.filter(session => this._shouldIncludeSession(session, mode, now, recentKeys)); - // The pass ran as `All`, so report the mode actually in effect instead. + // The pass ran as `Last30Days`, so report the mode actually in effect instead. this._logHiddenSessions(superset.length - visible.length, superset.length, mode); return visible; } diff --git a/src/vs/platform/agentHost/node/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index 15f3a9a19aadb..f2dc872ed5e69 100644 --- a/src/vs/platform/agentHost/node/agentSessionRegistry.ts +++ b/src/vs/platform/agentHost/node/agentSessionRegistry.ts @@ -69,6 +69,11 @@ export class AgentSessionRegistry extends Disposable { return this._database.registerSession(session.toString(), sessionOptions, registerOptions); } + /** Removes any registry entry for `session` without writing a tombstone. */ + async unregister(session: URI): Promise { + await this._database.unregisterSession(session.toString()); + } + /** * Removes any registry entry for `session` (a true delete) and durably * tombstones it so discovery cannot register it. Used both to delete a diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9f17a9b43c8a9..55e878fee42d0 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -94,11 +94,11 @@ async function createAgentSession(agent: IAgent, config?: IAgentCreateSessionCon return { session, ...chat, chat }; } -function discoveredChat(session: URI, external = true): IAgentDiscoveredChat { +function discoveredChat(session: URI, external = true, modifiedTime = Date.now()): IAgentDiscoveredChat { return { chat: URI.parse(buildDefaultChatUri(session)), - startTime: 1, - modifiedTime: 1, + startTime: modifiedTime, + modifiedTime, external, }; } @@ -1059,7 +1059,7 @@ suite('AgentService (node dispatcher)', () => { // Reopen: a fresh service on the same DB rediscovers the provider-native // session and must restore the persisted decision into `_meta`. const reopened = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const reopenedAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => reopenedAgent.dispose())); (reopenedAgent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); @@ -1117,7 +1117,7 @@ suite('AgentService (node dispatcher)', () => { await timeout(0); const reopened = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const reopenedAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => reopenedAgent.dispose())); (reopenedAgent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); @@ -2934,11 +2934,11 @@ suite('AgentService (node dispatcher)', () => { suite('aggregation', () => { class TimedExternalAgent extends MockAgent { - readonly catalog = new Map(); + readonly catalog = new Map(); - addSession(id: string, modifiedTime: number): URI { + addSession(id: string, modifiedTime: number, _meta?: IAgentSessionMetadata['_meta']): URI { const session = AgentSession.uri(this.id, id); - this.catalog.set(id, { session, modifiedTime }); + this.catalog.set(id, { session, modifiedTime, _meta }); (this as unknown as { _sessions: Map })._sessions.set(id, session); return session; } @@ -2948,13 +2948,14 @@ suite('AgentService (node dispatcher)', () => { chat: URI.parse(buildDefaultChatUri(entry.session)), startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, + ...(entry._meta ? { _meta: entry._meta } : {}), })); } override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { const session = resolveAgentChatContext(context, chat).configurationResource; const entry = this.catalog.get(AgentSession.id(session)); - return entry ? { chat, startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime } : undefined; + return entry ? { chat, startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, ...(entry._meta ? { _meta: entry._meta } : {}) } : undefined; } } @@ -3024,7 +3025,7 @@ suite('AgentService (node dispatcher)', () => { test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { const db = new TestSessionDatabase(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); @@ -3062,6 +3063,54 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(await db.getMetadata(AH_META_IS_READ_DB_KEY), ''); }); + test('discovery does not ingest external sessions older than 30 days', async () => { + const day = 24 * 60 * 60 * 1000; + const now = Date.now(); + const svc = createExternalSessionService(() => now); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const stale = agent.addSession('stale', now - 30 * day - 1); + const fresh = agent.addSession('fresh', now - 30 * day + 60_000); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); + await waitForSessionListReconciliation(svc); + svc.registerProvider(agent); + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ + { chat: URI.parse(buildDefaultChatUri(stale)), startTime: now - 30 * day - 1, modifiedTime: now - 30 * day - 1, external: true }, + { chat: URI.parse(buildDefaultChatUri(fresh)), startTime: now - 30 * day + 60_000, modifiedTime: now - 30 * day + 60_000, external: true }, + ]); + + const listed = (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(); + const registered = new Set((await svc.getRegisteredSessions()).map(session => session.toString())); + + assert.deepStrictEqual({ + listed, + registered: [...registered].sort(), + }, { + listed: [AgentSession.id(fresh)], + registered: [fresh.toString()], + }); + assert.ok(!registered.has(stale.toString())); + }); + + test('prune removes stale external sessions but keeps adoptable-legacy sessions', async () => { + const day = 24 * 60 * 60 * 1000; + const now = Date.now(); + const svc = createExternalSessionService(() => now); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const stale = agent.addSession('stale-prune', now - 30 * day - 1); + const staleAdoptable = agent.addSession('stale-adoptable', now - 30 * day - 1, withSessionEhcliAdoptable(undefined)); + const fresh = agent.addSession('fresh-prune', now - 30 * day); + svc.registerProvider(agent); + const sessionRegistry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; + await sessionRegistry.register(stale, { provider: 'copilot', startTime: now - 30 * day - 1, source: 'discovery' }, { checkTombstone: true }); + await sessionRegistry.register(staleAdoptable, { provider: 'copilot', startTime: now - 30 * day - 1, source: 'discovery' }, { checkTombstone: true }); + await sessionRegistry.register(fresh, { provider: 'copilot', startTime: now - 30 * day, source: 'discovery' }, { checkTombstone: true }); + + await (svc as unknown as { _pruneStaleExternalSessions(): Promise })._pruneStaleExternalSessions(); + const registered = (await sessionRegistry.list()).map(entry => entry.session.toString()).sort(); + + assert.deepStrictEqual(registered, [fresh.toString(), staleAdoptable.toString()].sort()); + }); + test('filters external sessions in every mode with inclusive time boundaries', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); @@ -3072,18 +3121,20 @@ suite('AgentService (node dispatcher)', () => { agent.addSession('older-than-24-hours', now - day - 1); agent.addSession('at-7-days', now - 7 * day); agent.addSession('older-than-7-days', now - 7 * day - 1); + agent.addSession('at-30-days', now - 30 * day); + agent.addSession('older-than-30-days', now - 30 * day - 1); svc.registerProvider(agent); const listedByMode: Record = { [AgentHostExternalSessionsMode.Recent]: [], [AgentHostExternalSessionsMode.None]: [], - [AgentHostExternalSessionsMode.All]: [], + [AgentHostExternalSessionsMode.Last30Days]: [], [AgentHostExternalSessionsMode.Last24Hours]: [], [AgentHostExternalSessionsMode.Last7Days]: [], }; const listedByDefault = (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(); let clientSeq = 1; - for (const mode of [AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.All, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days]) { + for (const mode of [AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.Last30Days, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days]) { setExternalSessionsMode(svc, mode, clientSeq++); await waitForSessionListReconciliation(svc); listedByMode[mode] = (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(); @@ -3094,7 +3145,7 @@ suite('AgentService (node dispatcher)', () => { listedByMode: { [AgentHostExternalSessionsMode.Recent]: ['at-24-hours', 'recent'], [AgentHostExternalSessionsMode.None]: [], - [AgentHostExternalSessionsMode.All]: ['at-24-hours', 'at-7-days', 'older-than-24-hours', 'older-than-7-days', 'recent'], + [AgentHostExternalSessionsMode.Last30Days]: ['at-24-hours', 'at-30-days', 'at-7-days', 'older-than-24-hours', 'older-than-7-days', 'recent'], [AgentHostExternalSessionsMode.Last24Hours]: ['at-24-hours', 'recent'], [AgentHostExternalSessionsMode.Last7Days]: ['at-24-hours', 'at-7-days', 'older-than-24-hours', 'recent'], }, @@ -3109,7 +3160,7 @@ suite('AgentService (node dispatcher)', () => { agent.addSession('external-one', now); agent.addSession('external-two', now); svc.registerProvider(agent); - await svc.listSessions(AgentHostExternalSessionsMode.All); + await svc.listSessions(AgentHostExternalSessionsMode.Last30Days); // A catalog pass otherwise opens every registered session's database, // so a mode that discards the row regardless must not pay for it. @@ -3124,7 +3175,7 @@ suite('AgentService (node dispatcher)', () => { const hidden = (await svc.listSessions(AgentHostExternalSessionsMode.None)).map(session => AgentSession.id(session.session)); const openedWhileHidden = [...new Set(opened)].sort(); opened.length = 0; - const visible = (await svc.listSessions(AgentHostExternalSessionsMode.All)).map(session => AgentSession.id(session.session)).sort(); + const visible = (await svc.listSessions(AgentHostExternalSessionsMode.Last30Days)).map(session => AgentSession.id(session.session)).sort(); assert.deepStrictEqual({ hidden, openedWhileHidden, visible, openedWhileVisible: [...new Set(opened)].sort() }, { hidden: [], @@ -3146,7 +3197,7 @@ suite('AgentService (node dispatcher)', () => { agent.addSession('yesterday', now - day); agent.addSession('last-week', now - 6 * day); svc.registerProvider(agent); - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 1); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); await waitForSessionListReconciliation(svc); // Each `listSessions` is one walk over every registered session's @@ -3172,7 +3223,7 @@ suite('AgentService (node dispatcher)', () => { transitionModes, visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), }, { - transitionModes: [AgentHostExternalSessionsMode.All], + transitionModes: [AgentHostExternalSessionsMode.Last30Days], visible: ['recent', 'yesterday'], }); }); @@ -3268,7 +3319,7 @@ suite('AgentService (node dispatcher)', () => { test('external discovery reconciles against a mode change that completes while registration is in flight', async () => { const now = Date.now(); const svc = createExternalSessionService(() => now); - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 1); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); svc.registerProvider(agent); @@ -3380,7 +3431,7 @@ suite('AgentService (node dispatcher)', () => { await waitForSessionListReconciliation(svc); notifications.length = 0; - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 2); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 2); await waitForSessionListReconciliation(svc); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.None, 3); await waitForSessionListReconciliation(svc); @@ -3391,7 +3442,7 @@ suite('AgentService (node dispatcher)', () => { test('unpublishes and republishes a restored external session as the configured mode changes', async () => { const now = Date.now(); const svc = createExternalSessionService(() => now); - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 1); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); const session = agent.addSession('restored-external', now); @@ -3411,7 +3462,7 @@ suite('AgentService (node dispatcher)', () => { setExternalSessionsMode(svc, AgentHostExternalSessionsMode.None, 2); await waitForSessionListReconciliation(svc); const hidden = (await svc.listSessions()).map(entry => entry.session.toString()); - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 3); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 3); await waitForSessionListReconciliation(svc); assert.deepStrictEqual({ @@ -3454,7 +3505,7 @@ suite('AgentService (node dispatcher)', () => { await svc.restoreSession(session); notifications.length = 0; - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 2); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 2); await waitForSessionListReconciliation(svc); assert.deepStrictEqual({ @@ -3714,12 +3765,12 @@ suite('AgentService (node dispatcher)', () => { override async listExternalChats(): Promise { this.externalCalls++; - return [{ chat: URI.parse(buildDefaultChatUri(external)), startTime: 1, modifiedTime: 1 }]; + return [{ chat: URI.parse(buildDefaultChatUri(external)), startTime: Date.now(), modifiedTime: Date.now() }]; } override async listChatsToMigrate(): Promise { this.legacyCalls++; - return [{ chat: URI.parse(buildDefaultChatUri(legacy)), startTime: 2, modifiedTime: 2 }]; + return [{ chat: URI.parse(buildDefaultChatUri(legacy)), startTime: Date.now(), modifiedTime: Date.now() }]; } override fireDiscoveredChats(chats: readonly IAgentDiscoveredChat[]): void { this._onDidDiscoverChats.fire(chats); } @@ -3811,8 +3862,8 @@ suite('AgentService (node dispatcher)', () => { await (svc as unknown as { _announceSurfacedSession(meta: IAgentSessionMetadata, provider: string): Promise })._announceSurfacedSession({ session, - startTime: 1, - modifiedTime: 1, + startTime: Date.now(), + modifiedTime: Date.now(), }, agent.id); assert.strictEqual(svc.stateManager.getSurfacedSessionSummary(session.toString())?.resource, session.toString()); }); @@ -3821,8 +3872,8 @@ suite('AgentService (node dispatcher)', () => { class MixedMigrationAgent extends MockAgent { override async listChatsToMigrate(): Promise { return [ - { chat: URI.parse(buildDefaultChatUri(restored)), startTime: 1, modifiedTime: 1 }, - { chat: URI.parse(buildDefaultChatUri(external)), startTime: 2, modifiedTime: 2 }, + { chat: URI.parse(buildDefaultChatUri(restored)), startTime: Date.now(), modifiedTime: Date.now() }, + { chat: URI.parse(buildDefaultChatUri(external)), startTime: Date.now(), modifiedTime: Date.now() }, ]; } } @@ -3957,7 +4008,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new GatedListAgent('copilot')); svc.registerProvider(agent); const legacy = AgentSession.uri('copilot', 'legacy-concurrent'); @@ -4007,7 +4058,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new TransientListFailureAgent('copilot')); svc.registerProvider(agent); const legacy = AgentSession.uri('copilot', 'legacy-session'); @@ -4028,7 +4079,7 @@ suite('AgentService (node dispatcher)', () => { test('a late-registered provider gets its own native discovery pass', async () => { const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const early = disposables.add(new MockAgent('copilot')); svc.registerProvider(early); @@ -4166,7 +4217,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const providerA = disposables.add(new CountingAgent('copilot')); const providerB = disposables.add(new FailingThenRecoveringAgent('other')); @@ -4217,7 +4268,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetEnumerableAgent('copilot')); const originalListExternalChats = agent.listExternalChats.bind(agent); (agent as unknown as { listExternalChats: () => Promise }).listExternalChats = async () => { @@ -4255,7 +4306,7 @@ suite('AgentService (node dispatcher)', () => { await db.registerSession(existing.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); const writesBeforeUnavailable = db.registryWriteAttempts; const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); @@ -4263,7 +4314,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { listChatsToMigrate: () => Promise }).listChatsToMigrate = async () => { agent.migrationCalls++; return agent.enumerable - ? [{ chat: URI.parse(buildDefaultChatUri(legacy)), startTime: 1, modifiedTime: 1 }] + ? [{ chat: URI.parse(buildDefaultChatUri(legacy)), startTime: Date.now(), modifiedTime: Date.now() }] : undefined; }; svc.registerProvider(agent); @@ -4321,14 +4372,14 @@ suite('AgentService (node dispatcher)', () => { await timeout(0); } - const all = svc.listSessions(AgentHostExternalSessionsMode.All); + const last30Days = svc.listSessions(AgentHostExternalSessionsMode.Last30Days); const recent = svc.listSessions(AgentHostExternalSessionsMode.Recent); for (let i = 0; i < 20 && agent.catalogCalls < 2; i++) { await timeout(0); } assert.strictEqual(agent.catalogCalls, 2, 'overlapping computations must share the replacement retry'); retryGate.complete(); - await Promise.all([all, recent]); + await Promise.all([last30Days, recent]); assert.strictEqual(agent.catalogCalls, 2, 'a losing caller must await the installed retry instead of queueing another'); }); @@ -4345,7 +4396,8 @@ suite('AgentService (node dispatcher)', () => { } const db = new TransientRegistryWriteDatabase(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const copilot = disposables.add(new CatalogAgent('copilot')); const claude = disposables.add(new CatalogAgent('claude')); const copilotSession = AgentSession.uri('copilot', 'complete-provider'); @@ -4598,7 +4650,7 @@ suite('AgentService (node dispatcher)', () => { // Simulate an old database whose legacy one-time marker is set. await db.markSessionRegistryBackfilled(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new CountingAgent('copilot')); const legacy = AgentSession.uri('copilot', 'old-db-native-session'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); @@ -4812,7 +4864,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4858,7 +4910,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4881,7 +4933,7 @@ suite('AgentService (node dispatcher)', () => { }; (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4902,7 +4954,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4920,7 +4972,7 @@ suite('AgentService (node dispatcher)', () => { }; (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4946,7 +4998,7 @@ suite('AgentService (node dispatcher)', () => { return []; }; const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4981,7 +5033,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getWorktreeRoots = async () => [primaryRoot, linkedCheckout, sessionWorktree]; const sessionDataService = createSessionDataService(db); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -5028,7 +5080,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); const sessionDataService = createSessionDataService(db); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -5189,7 +5241,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = URI.file('/original'); const { session } = await createAgentSession(agent); - setExternalSessionsMode(service, AgentHostExternalSessionsMode.All, 1); + setExternalSessionsMode(service, AgentHostExternalSessionsMode.Last30Days, 1); await waitForSessionListReconciliation(service); service.registerProvider(agent); agent.fireDiscoveredChats([discoveredChat(session)]); @@ -5199,13 +5251,14 @@ suite('AgentService (node dispatcher)', () => { const listing = service.listSessions(); await agent.listStarted.p; + const summaryNow = Date.now(); service.stateManager.restoreSession({ resource: session.toString(), provider: 'copilot', title: 'Materialized', status: SessionStatus.Idle, - createdAt: new Date(1000).toISOString(), - modifiedAt: new Date(2000).toISOString(), + createdAt: new Date(summaryNow - 1_000).toISOString(), + modifiedAt: new Date(summaryNow).toISOString(), project: { uri: URI.file('/project').toString(), displayName: 'project' }, workingDirectories: [URI.file('/worktree').toString()], }, []); @@ -5217,7 +5270,7 @@ suite('AgentService (node dispatcher)', () => { project: listed?.project && { uri: listed.project.uri.path, displayName: listed.project.displayName }, workingDirectory: listed?.workingDirectories?.[0]?.path, }, { - modifiedTime: 2000, + modifiedTime: summaryNow, project: { uri: '/project', displayName: 'project' }, workingDirectory: '/worktree', }); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts index 074390d867081..601dec1dd2383 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts @@ -135,7 +135,7 @@ suite('Protocol WebSocket — Session Lifecycle', function () { clientSeq: 1, action: { type: 'root/configChanged', - config: { [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }, + config: { [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }, }, }); await client.call('ping'); diff --git a/src/vs/platform/chat/common/chatSettings.ts b/src/vs/platform/chat/common/chatSettings.ts index 397749566d3ea..6ed932c63b031 100644 --- a/src/vs/platform/chat/common/chatSettings.ts +++ b/src/vs/platform/chat/common/chatSettings.ts @@ -12,9 +12,9 @@ export type ChatEditAutoApprovePatterns = Readonly>; export const enum ChatExternalSessionsMode { Recent = 'recent', None = 'none', - All = 'all', Last24Hours = 'last24Hours', Last7Days = 'last7Days', + Last30Days = 'last30Days', } /** Edit paths whose executable side effects require confirmation regardless of user configuration. */ diff --git a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts index ad38790bb8097..8280e1178a0a5 100644 --- a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts +++ b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts @@ -47,12 +47,12 @@ export function shouldConfirmExternalSessionVisibilityChange(mode: ChatExternalS return true; case ChatExternalSessionsMode.None: return true; - case ChatExternalSessionsMode.All: - return false; case ChatExternalSessionsMode.Last24Hours: return updatedAt.getTime() < now - DAY; case ChatExternalSessionsMode.Last7Days: return updatedAt.getTime() < now - 7 * DAY; + case ChatExternalSessionsMode.Last30Days: + return updatedAt.getTime() < now - 30 * DAY; } } @@ -86,7 +86,9 @@ export function getExternalSessionVisibilityConfirmation(mode: ChatExternalSessi : localize('externalSessionBanner.confirm.daysAgo', "{0} days ago", daysAgo); const detail = mode === ChatExternalSessionsMode.Last24Hours ? localize('externalSessionBanner.confirm.lastDay.detail', "Only external sessions updated in the last day will be shown. This session was last updated {0}. Are you sure you want to save this change?", lastUpdated) - : localize('externalSessionBanner.confirm.last7Days.detail', "Only external sessions updated in the last 7 days will be shown. This session was last updated {0}. Are you sure you want to save this change?", lastUpdated); + : mode === ChatExternalSessionsMode.Last7Days + ? localize('externalSessionBanner.confirm.last7Days.detail', "Only external sessions updated in the last 7 days will be shown. This session was last updated {0}. Are you sure you want to save this change?", lastUpdated) + : localize('externalSessionBanner.confirm.last30Days.detail', "Only external sessions updated in the last 30 days will be shown. This session was last updated {0}. Are you sure you want to save this change?", lastUpdated); return { type: 'warning', message, detail, primaryButton }; } @@ -245,10 +247,10 @@ export class ExternalSessionBanner extends Disposable { }, }, { - mode: ChatExternalSessionsMode.All, + mode: ChatExternalSessionsMode.Last30Days, item: { - text: localize('externalSessionBanner.select.all', "All"), - description: localize('externalSessionBanner.select.all.description', "Show all sessions created in another application."), + text: localize('externalSessionBanner.select.last30Days', "Last 30 Days"), + description: localize('externalSessionBanner.select.last30Days.description', "Show external sessions updated in the last 30 days."), }, }, ]; diff --git a/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts b/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts index c5841d767e591..10a45a6ea21c7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts @@ -18,7 +18,8 @@ suite('Sessions - External Session Banner', () => { assert.deepStrictEqual({ recent: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Recent, new Date(now), now), none: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.None, new Date(now), now), - all: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.All, new Date(0), now), + at30Days: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last30Days, new Date(now - 30 * day), now), + olderThan30Days: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last30Days, new Date(now - 30 * day - 1), now), at24Hours: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last24Hours, new Date(now - day), now), olderThan24Hours: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last24Hours, new Date(now - day - 1), now), at7Days: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last7Days, new Date(now - 7 * day), now), @@ -26,7 +27,8 @@ suite('Sessions - External Session Banner', () => { }, { recent: true, none: true, - all: false, + at30Days: false, + olderThan30Days: true, at24Hours: false, olderThan24Hours: true, at7Days: false, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts index 603d6cec71614..1f4fbdf88233d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts @@ -17,7 +17,7 @@ const externalSessionOptions = [ { mode: ChatExternalSessionsMode.Recent, title: localize2('agentSessions.filter.external.recent', "Recent") }, { mode: ChatExternalSessionsMode.Last24Hours, title: localize2('agentSessions.filter.external.last24Hours', "Last 24 Hours") }, { mode: ChatExternalSessionsMode.Last7Days, title: localize2('agentSessions.filter.external.last7Days', "Last 7 Days") }, - { mode: ChatExternalSessionsMode.All, title: localize2('agentSessions.filter.external.all', "All") }, + { mode: ChatExternalSessionsMode.Last30Days, title: localize2('agentSessions.filter.external.last30Days', "Last 30 Days") }, ] as const; export function registerExternalSessionsFilterMenu(parentMenuId: MenuId, submenuId: MenuId, group: string): IDisposable { 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 caf77a01582b5..50f8e61d9ebbd 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -397,13 +397,13 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.ShowExternalAgentSessions]: { type: 'string', - enum: [AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days, AgentHostExternalSessionsMode.All], + enum: [AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days, AgentHostExternalSessionsMode.Last30Days], enumDescriptions: [ nls.localize('chat.agentSessions.showExternal.none', "Only shows sessions created by the Agent Host."), nls.localize('chat.agentSessions.showExternal.recent', "Shows the 2 most recently updated external sessions from the last 7 days."), nls.localize('chat.agentSessions.showExternal.last24Hours', "Shows external sessions updated in the last 24 hours."), nls.localize('chat.agentSessions.showExternal.last7Days', "Shows external sessions updated in the last 7 days."), - nls.localize('chat.agentSessions.showExternal.all', "Shows all sessions discovered from supported external agent applications."), + nls.localize('chat.agentSessions.showExternal.last30Days', "Shows external sessions updated in the last 30 days."), ], default: AgentHostExternalSessionsMode.None, markdownDescription: nls.localize('chat.agentSessions.showExternal', "Controls which external agent sessions, created outside VS Code's Agent Host, are shown."), @@ -2439,6 +2439,14 @@ Registry.as(Extensions.ConfigurationMigration). return { value }; } }, + { + key: ChatConfiguration.ShowExternalAgentSessions, + migrateFn: (value: unknown) => ({ + value: value === 'all' + ? AgentHostExternalSessionsMode.Last30Days + : value, + }) + }, { key: ChatConfiguration.NotifyWindowOnConfirmation, migrateFn: (value: unknown) => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts index ea545123c3450..80b5849b556a0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts @@ -59,7 +59,7 @@ suite('External Sessions Filter Menu', () => { { title: 'Recent', checkedForRecent: true }, { title: 'Last 24 Hours', checkedForRecent: false }, { title: 'Last 7 Days', checkedForRecent: false }, - { title: 'All', checkedForRecent: false }, + { title: 'Last 30 Days', checkedForRecent: false }, ], }); }); From 97a9b4add6c8b550be428a23781ad59908e1d87d Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 20 Aug 2026 19:12:20 +0200 Subject: [PATCH 11/29] agentHost: keep peer database alive during multi-root diffs (#331759) Await multi-root turn diff computation before releasing the tracked-edit database reference. Add a regression test that verifies asynchronous peer database reads finish before disposal.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostChangesetService.ts | 2 +- .../node/agentHostChangesetService.test.ts | 40 ++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index a0fc9e7fb109c..e29b9d17b1478 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -718,7 +718,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); if (isMultiRootSession(workingDirectories)) { - return this._computeMultiFolderTurnDiffs(session, trackedSource.sessionUri, trackedSource.db, turnId, workingDirectories!); + return await this._computeMultiFolderTurnDiffs(session, trackedSource.sessionUri, trackedSource.db, turnId, workingDirectories!); } const diffs = await this._computeSingleFolderTurnDiffs(session, trackedSource.sessionUri, trackedSource.db, turnId); return { diffs, outcome: 'computed' }; diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index e362c15476d86..265519a4400e6 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -1385,7 +1385,7 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { log?: RecordingLogService; telemetry?: ITelemetryService; subscriptions?: string[]; - peer?: { resource: string; db: TestSessionDatabase; turnId: string }; + peer?: { resource: string; db: TestSessionDatabase; turnId: string; onDispose?: () => void }; }): { svc: AgentHostChangesetService; stateManager: AgentHostStateManager; log: RecordingLogService } { const log = options.log ?? new RecordingLogService(); const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -1406,9 +1406,19 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { log, { ...sessionDataService, - openDatabase: resource => options.peer?.resource === resource.toString() - ? peerDataService!.openDatabase(resource) - : sessionDataService.openDatabase(resource), + openDatabase: resource => { + if (options.peer?.resource !== resource.toString()) { + return sessionDataService.openDatabase(resource); + } + const ref = peerDataService!.openDatabase(resource); + return { + object: ref.object, + dispose: () => { + options.peer?.onDispose?.(); + ref.dispose(); + }, + }; + }, }, options.git, options.checkpoint, @@ -1502,7 +1512,15 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { test('uses the owning peer database for multi-root non-git fallback', async () => { const sessionDb = new TestSessionDatabase(); - const peerDb = new TestSessionDatabase(); + const lifecycle: string[] = []; + class DelayedPeerDatabase extends TestSessionDatabase { + override async getFileEditsByTurn(turnId: string) { + await timeout(0); + lifecycle.push('read'); + return super.getFileEditsByTurn(turnId); + } + } + const peerDb = new DelayedPeerDatabase(); peerDb.addEdit({ turnId: 'peer-turn', toolCallId: 'tc1', filePath: '/folderA/peer.txt', kind: FileEditKind.Edit, addedLines: undefined, removedLines: undefined, beforeContent: encodeString('a'), afterContent: encodeString('a\nb') }); const peerResource = 'ahp-chat://peer-1/session-mr'; const { svc, stateManager } = build({ @@ -1510,14 +1528,18 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { git: createNoopGitService(), checkpoint: NULL_CHECKPOINT_SERVICE, db: sessionDb, - peer: { resource: peerResource, db: peerDb, turnId: 'peer-turn' }, + peer: { resource: peerResource, db: peerDb, turnId: 'peer-turn', onDispose: () => lifecycle.push('dispose') }, }); const turnUri = await svc.computeTurnChangeset(sessionStr, 'peer-turn'); - assert.deepStrictEqual(stateManager.getChangesetState(turnUri)?.files.map(file => file.id), [ - URI.file('/folderA/peer.txt').toString(), - ]); + assert.deepStrictEqual({ + files: stateManager.getChangesetState(turnUri)?.files.map(file => file.id), + lifecycle, + }, { + files: [URI.file('/folderA/peer.txt').toString()], + lifecycle: ['read', 'dispose'], + }); }); test('diffs a repository shared by two working directories exactly once (dedup by repo root)', async () => { From d06ea12911bb964f60b88ef5a8313d7c33d002c4 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 20 Aug 2026 18:25:03 +0100 Subject: [PATCH 12/29] Align chat UI with design tokens (#331791) Implement code changes to enhance functionality and improve performance Co-authored-by: mrleemurray --- .../chat/browser/widget/media/chat.css | 476 +++++++++--------- 1 file changed, 250 insertions(+), 226 deletions(-) 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 4f16c1ee51067..f9c8c461361d8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -88,8 +88,8 @@ .interactive-item-container .header .username { margin: 0; - font-size: 13px; - font-weight: 600; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); } .interactive-item-container .detail-container { @@ -165,8 +165,8 @@ justify-content: center; width: 24px; height: 24px; - border-radius: 50%; - outline: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-circle); + outline: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); } .interactive-item-container .header .avatar.codicon-avatar { @@ -180,13 +180,13 @@ .interactive-item-container .header .avatar .icon { width: 24px; height: 24px; - border-radius: 50%; + border-radius: var(--vscode-cornerRadius-circle); background-color: var(--vscode-chat-list-background); } .interactive-item-container .header .avatar .codicon { color: var(--vscode-chat-avatarForeground) !important; - font-size: 14px; + font-size: var(--vscode-codiconFontSize); } .interactive-item-container:not(:hover):not(:focus-within) .header .monaco-toolbar, @@ -199,8 +199,13 @@ } .interactive-item-container .header .monaco-toolbar .action-label { - border: 1px solid transparent; - padding: 2px; + box-sizing: border-box; + width: 22px; + height: 22px; + border: var(--vscode-strokeThickness) solid transparent; + padding: 0; + align-items: center; + justify-content: center; } .interactive-item-container.interactive-response .header .monaco-toolbar { @@ -542,7 +547,7 @@ } .interactive-item-container > .value .chat-used-context { - margin-bottom: 14px; + margin-bottom: var(--vscode-spacing-size160); } .interactive-item-container > .value .chat-used-context .chat-used-context-list { @@ -564,13 +569,13 @@ padding: 0px 16px 0 10px; border-left-width: 5px; border-left-style: solid; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); background: var(--vscode-textBlockQuote-background); border-color: var(--vscode-textBlockQuote-border); } .interactive-item-container .value .rendered-markdown strong { - font-weight: 600; + font-weight: var(--vscode-fontWeight-semiBold); } .interactive-item-container .value .rendered-markdown .rendered-markdown-table-scroll-wrapper { @@ -584,12 +589,12 @@ overflow: hidden; border-collapse: separate; border-spacing: 0; - border: 1px solid var(--vscode-chat-requestBorder); + border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); } .interactive-item-container .value .rendered-markdown table td, .interactive-item-container .value .rendered-markdown table th { - border: 1px solid var(--vscode-chat-requestBorder); + border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); border-top: none; border-left: none; padding: 4px 6px; @@ -651,7 +656,7 @@ } .chat-list-at-bottom .chat-progress-reservable > .value:has(> .rendered-markdown:last-child) { - padding-bottom: 37px; + padding-bottom: var(--vscode-spacing-size360); } .interactive-item-container .value > :last-child, @@ -661,16 +666,12 @@ } .interactive-item-container .value .rendered-markdown hr { - border-color: rgba(0, 0, 0, 0.18); -} - -.vs-dark .interactive-item-container .value .rendered-markdown hr { - border-color: rgba(255, 255, 255, 0.18); + border-color: color-mix(in srgb, var(--vscode-textSeparator-foreground) 33%, transparent); } .interactive-item-container .value .rendered-markdown h1 { font-size: var(--vscode-chat-font-size-body-xxl); - font-weight: 600; + font-weight: var(--vscode-fontWeight-semiBold); margin: 1.5em 0 0.875em 0; font-family: var(--vscode-chat-font-family, inherit); @@ -678,14 +679,14 @@ .interactive-item-container .value .rendered-markdown h2 { font-size: var(--vscode-chat-font-size-body-xl); - font-weight: 600; + font-weight: var(--vscode-fontWeight-semiBold); margin: 1.5em 0 0.875em 0; font-family: var(--vscode-chat-font-family, inherit); } .interactive-item-container .value .rendered-markdown h3 { font-size: var(--vscode-chat-font-size-body-l); - font-weight: 600; + font-weight: var(--vscode-fontWeight-semiBold); margin: 1.5em 0 0.875em 0; font-family: var(--vscode-chat-font-family, inherit); } @@ -838,7 +839,7 @@ } .tool-output-part { - border: 1px solid var(--vscode-widget-border); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); border-radius: var(--vscode-cornerRadius-medium); background: var(--vscode-editor-background); margin: 4px 0; @@ -847,7 +848,7 @@ .output-title { padding: 8px 12px; background: var(--vscode-editorWidget-background); - border-bottom: 1px solid var(--vscode-widget-border); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-widget-border); font-size: var(--vscode-chat-font-size-body-m); } @@ -873,7 +874,7 @@ .output-error-header { display: flex; align-items: center; - gap: 7px; + gap: var(--vscode-spacing-size80); margin-bottom: 4px; .codicon-error { @@ -889,7 +890,7 @@ } &:not(:last-child) { - margin-bottom: 14px; + margin-bottom: var(--vscode-spacing-size160); } } @@ -962,9 +963,9 @@ have to be updated for changes to the rules above, or to support more deeply nes font-size: var(--vscode-chat-font-size-body-xs); color: var(--vscode-textPreformat-foreground); background-color: var(--vscode-textPreformat-background); - padding: 1px 3px; - border-radius: 4px; - border: 1px solid var(--vscode-textPreformat-border); + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-small); + border: var(--vscode-strokeThickness) solid var(--vscode-textPreformat-border); white-space: pre-wrap; } } @@ -988,7 +989,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-item-container.interactive-item-compact .header .codicon-avatar .codicon { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); } .interactive-item-container.interactive-item-compact .header .avatar + .avatar { @@ -1082,9 +1083,9 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-dnd-overlay .attach-context-overlay-text .codicon { - height: 12px; - font-size: 12px; - margin-right: 3px; + height: var(--vscode-codiconFontSize-compact); + font-size: var(--vscode-codiconFontSize-compact); + margin-right: var(--vscode-spacing-size40); } /* @@ -1117,7 +1118,7 @@ have to be updated for changes to the rules above, or to support more deeply nes box-sizing: border-box; cursor: text; background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-input-border, transparent); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); /* Top corners follow the stack, so a docked widget can square them. The fallback keeps an input rendered outside a chat input part rounded. */ --chat-input-own-radius: var(--chat-input-radius, var(--vscode-cornerRadius-large)); @@ -1188,7 +1189,7 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Inherit so the ring matches whatever corner radius the container is currently using (large by default, small in compact mode). */ border-radius: inherit; - padding: 1px; + padding: var(--vscode-strokeThickness); /* The beam: a tight bright arc (~40deg) with a short fade, on an otherwise transparent ring. As `--chat-input-anim-angle` rotates, the bright spot travels around the perimeter like a comet. Stops are mostly transparent @@ -1365,7 +1366,7 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Focus indicator drawn on the action-item wrapper so it sits cleanly around the button surface with a small offset. */ .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button:not(.disabled):has(> .action-label:focus-visible) { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: 1px; border-radius: var(--vscode-cornerRadius-circle); } @@ -1393,10 +1394,10 @@ have to be updated for changes to the rules above, or to support more deeply nes } .monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { - padding: 4px 3px 4px 3px; + padding: var(--vscode-spacing-size40); box-sizing: border-box; background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-input-border, transparent); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); border-bottom: none; border-radius: var(--chat-input-stack-radius-top, var(--vscode-cornerRadius-large)) var(--chat-input-stack-radius-top, var(--vscode-cornerRadius-large)) 0 0; display: flex; @@ -1406,7 +1407,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-editing-session .monaco-list-row .chat-collapsible-list-action-bar { - padding-left: 5px; + padding-left: var(--vscode-spacing-size60); display: none; } @@ -1417,7 +1418,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-editing-session .chat-editing-session-container.show-file-icons .monaco-scrollable-element .monaco-list-rows .monaco-list-row { - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); } /* Toggled in chatInputPart.ts when the working set overflows. Do not replace @@ -1431,7 +1432,7 @@ have to be updated for changes to the rules above, or to support more deeply nes flex-direction: row; justify-content: space-between; gap: 6px; - padding-right: 3px; + padding-right: var(--vscode-spacing-size20); height: 22px; line-height: 22px; cursor: pointer; @@ -1439,7 +1440,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-editing-session .chat-editing-session-container .chat-editing-session-overview > .working-set-title { color: var(--vscode-descriptionForeground); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1455,8 +1456,8 @@ have to be updated for changes to the rules above, or to support more deeply nes display: inline-flex; gap: 4px; margin-left: 6px; - font-size: 11px; - font-weight: 500; + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-semiBold); } .interactive-session .chat-editing-session .chat-editing-session-container .chat-editing-session-overview .working-set-line-counts .working-set-lines-added { @@ -1471,7 +1472,7 @@ have to be updated for changes to the rules above, or to support more deeply nes margin: 0 6px; display: inline-flex; gap: 4px; - font-size: 11px; + font-size: var(--vscode-fontSize-label2); } .interactive-session .chat-editing-session .chat-editing-session-list .working-set-line-counts .working-set-lines-added { @@ -1485,8 +1486,8 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-editing-session .working-set-title { .monaco-button { - padding: 4px 6px 4px 0px; - border-radius: 2px; + padding: 2px 6px 2px 0px; + border-radius: var(--vscode-cornerRadius-xSmall); border: none; background-color: unset; color: var(--vscode-descriptionForeground); @@ -1512,7 +1513,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-editing-session .chat-editing-session-toolbar-actions { - margin: 3px 0px; + margin: var(--vscode-spacing-size40) 0; overflow: hidden; } @@ -1537,12 +1538,12 @@ have to be updated for changes to the rules above, or to support more deeply nes display: flex; align-items: center; justify-content: center; - font-size: 16px; + font-size: var(--vscode-codiconFontSize); color: var(--vscode-descriptionForeground); background-color: transparent; border: none; padding: 0; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; } @@ -1569,7 +1570,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { cursor: pointer; padding: 2px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); display: inline-flex; } @@ -1600,19 +1601,19 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button { align-items: center; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); } .interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button .monaco-button, .interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button .monaco-button:hover { - border-right: 1px solid transparent; + border-right: var(--vscode-strokeThickness) solid transparent; background-color: unset; padding: 0; } .interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button > .separator { - border-right: 1px solid transparent; - padding: 0 1px; + border-right: var(--vscode-strokeThickness) solid transparent; + padding: 0 var(--vscode-spacing-size20); height: 22px; } @@ -1637,9 +1638,9 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget { - padding: 4px 3px 4px 3px; + padding: var(--vscode-spacing-size40); box-sizing: border-box; - border: 1px solid var(--vscode-input-border, transparent); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); background-color: var(--vscode-editor-background); border-bottom: none; border-radius: var(--chat-input-stack-radius-top, var(--vscode-cornerRadius-large)) var(--chat-input-stack-radius-top, var(--vscode-cornerRadius-large)) 0 0; @@ -1671,11 +1672,11 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-list-expand .todo-list-title-section { - padding-left: 3px; + padding-left: var(--vscode-spacing-size40); display: flex; align-items: center; flex: 1; - font-size: 12px; + font-size: var(--vscode-fontSize-label1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1683,7 +1684,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-list-expand .todo-list-title-section .codicon { - font-size: 16px; + font-size: var(--vscode-codiconFontSize); line-height: 22px; flex-shrink: 0; } @@ -1692,7 +1693,7 @@ have to be updated for changes to the rules above, or to support more deeply nes padding-right: 2px; display: flex; align-items: center; - height: 18px; + height: 22px; opacity: 1; } @@ -1701,13 +1702,15 @@ have to be updated for changes to the rules above, or to support more deeply nes border-color: transparent; color: var(--vscode-foreground); cursor: pointer; - height: 16px; - padding: 3px; - border-radius: 2px; + box-sizing: border-box; + width: 22px; + height: 22px; + min-width: 22px; + padding: 0; + border-radius: var(--vscode-cornerRadius-xSmall); display: inline-flex; align-items: center; justify-content: center; - min-width: unset; } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-clear-button-container .monaco-button:hover { @@ -1715,24 +1718,24 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-clear-button-container .monaco-button:focus { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: 1px; } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-clear-button-container .monaco-button .codicon { - font-size: 10px; + font-size: var(--vscode-codiconFontSize-compact); color: var(--vscode-foreground); } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .expand-icon { flex-shrink: 0; - margin-right: 3px; + margin-right: var(--vscode-spacing-size40); } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-list-title { - font-weight: normal; - font-size: 12px; + font-weight: var(--vscode-fontWeight-regular); + font-size: var(--vscode-fontSize-label1); display: flex; align-items: center; overflow: hidden; @@ -1767,14 +1770,14 @@ have to be updated for changes to the rules above, or to support more deeply nes scroll-snap-align: start; min-height: 22px; font-size: var(--vscode-chat-font-size-body-m); - padding: 0px 3px; - border-radius: 2px; + padding: 0 var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-xSmall); cursor: pointer; } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-item:focus { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: -1px; } @@ -1784,7 +1787,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-item > .todo-status-icon.codicon { flex-shrink: 0; - font-size: 16px; + font-size: var(--vscode-codiconFontSize); } .interactive-session .interactive-input-part .chat-todo-list-widget-container .chat-todo-list-widget .todo-content { @@ -1862,7 +1865,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-editor-container .monaco-editor .chat-prompt-spinner { transform-origin: 6px 6px; - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); } .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor, @@ -1944,7 +1947,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .codicon { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); } } @@ -1955,9 +1958,10 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label { - height: 16px; - padding: 3px 8px; - border-radius: 4px; + box-sizing: border-box; + height: 22px; + padding: 0 var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-small); display: flex; align-items: center; color: var(--vscode-icon-foreground); @@ -1980,7 +1984,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .monaco-workbench .interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label .codicon-chevron-down { - font-size: 10px; + font-size: var(--vscode-codiconFontSize-compact); margin-left: 4px; opacity: 0.75; } @@ -1997,12 +2001,12 @@ have to be updated for changes to the rules above, or to support more deeply nes position: absolute; bottom: 0; right: 0; - font-size: 12px !important; + font-size: var(--vscode-codiconFontSize-compact) !important; color: var(--vscode-problemsWarningIcon-foreground); background: var(--vscode-input-background); width: fit-content; height: fit-content; - border-radius: 100%; + border-radius: var(--vscode-cornerRadius-circle); } .interactive-session .chat-input-toolbars > .chat-input-toolbar { @@ -2035,7 +2039,7 @@ have to be updated for changes to the rules above, or to support more deeply nes align-items: center; margin-left: 4px; flex-shrink: 0; - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); .codicon.codicon-info { color: var(--vscode-problemsInfoIcon-foreground) !important; @@ -2051,7 +2055,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .codicon { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); } } @@ -2069,17 +2073,19 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label, .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label { - height: 16px; - padding: 3px 6px; + box-sizing: border-box; + height: 22px; + padding: 0 var(--vscode-spacing-size60); display: flex; align-items: center; color: var(--vscode-icon-foreground); } .interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label { - height: 16px; - padding: 3px 8px; - border-radius: 4px; + box-sizing: border-box; + height: 22px; + padding: 0 var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-small); display: flex; align-items: center; color: var(--vscode-icon-foreground); @@ -2126,7 +2132,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-workbench .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, .monaco-workbench .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down, .monaco-workbench .interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down { - font-size: 10px; + font-size: var(--vscode-codiconFontSize-compact); margin-left: 4px; opacity: 0.75; } @@ -2199,7 +2205,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-editor .chat-editing-last-edit { background-color: var(--vscode-editor-rangeHighlightBackground); box-sizing: border-box; - border: 1px solid var(--vscode-editor-rangeHighlightBorder); + border: var(--vscode-strokeThickness) solid var(--vscode-editor-rangeHighlightBorder); } @property --chat-editing-last-edit-shift { @@ -2253,7 +2259,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-response .interactive-response-error-details .codicon { - margin-top: 1px; + margin-top: var(--vscode-spacing-size20); } .chat-used-context-list .codicon-warning { @@ -2359,10 +2365,10 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-attached-context .chat-attached-context-attachment { display: flex; overflow: hidden; - font-size: 11px; + font-size: var(--vscode-fontSize-label2); padding: 0 4px 0 0; - border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); - border-radius: 4px; + border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); + border-radius: var(--vscode-cornerRadius-small); height: 18px; max-width: 100%; width: fit-content; @@ -2380,11 +2386,11 @@ have to be updated for changes to the rules above, or to support more deeply nes position: absolute; bottom: 0; left: 12px; - font-size: 12px !important; + font-size: var(--vscode-codiconFontSize-compact) !important; background: var(--vscode-input-background); width: fit-content; height: fit-content; - border-radius: 100%; + border-radius: var(--vscode-cornerRadius-circle); pointer-events: none; &.chat-mcp-state-new { @@ -2410,10 +2416,10 @@ have to be updated for changes to the rules above, or to support more deeply nes align-items: center; margin-top: -2px; padding-right: 2px; - padding-left: 3px; + padding-left: var(--vscode-spacing-size40); height: calc(100% + 4px); outline-offset: -4px; - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); } .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button.codicon.codicon-add-compact { @@ -2462,7 +2468,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label .codicon { - font-size: 14px; + font-size: var(--vscode-codiconFontSize-compact); } .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label .monaco-icon-label-iconpath.codicon { @@ -2554,7 +2560,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session-followups .monaco-button .codicon { margin-left: 0; - margin-top: 1px; + margin-top: var(--vscode-spacing-size20); } .interactive-item-container .interactive-response-followups .monaco-button { @@ -2568,7 +2574,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups .monaco-button { display: block; color: var(--vscode-textLink-foreground); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); /* clamp to max 3 lines */ display: -webkit-box; @@ -2580,7 +2586,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups code { font-family: var(--monaco-monospace-font); - font-size: 11px; + font-size: var(--vscode-fontSize-body2); } .interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups .monaco-button .codicon-sparkle { @@ -2605,7 +2611,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .quick-input-widget .interactive-session .interactive-input-part { padding: 8px 6px 8px 6px; - margin: 0 3px; + margin: 0 var(--vscode-spacing-size40); } .quick-input-widget .interactive-session .interactive-input-part .chat-input-toolbars .monaco-toolbar, @@ -2614,7 +2620,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .quick-input-widget .interactive-session .interactive-input-part .chat-input-toolbars { - margin-bottom: 1px; + margin-bottom: var(--vscode-spacing-size20); align-items: flex-end; } @@ -2624,8 +2630,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } .quick-input-widget .interactive-list { - border-bottom-right-radius: 6px; - border-bottom-left-radius: 6px; + border-bottom-right-radius: var(--vscode-cornerRadius-medium); + border-bottom-left-radius: var(--vscode-cornerRadius-medium); } .quick-input-widget .interactive-response { @@ -2635,7 +2641,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .quick-input-widget .interactive-session .disclaimer { margin: 8px 12px; color: var(--vscode-descriptionForeground); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); a { color: var(--vscode-textLink-foreground); @@ -2666,7 +2672,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container .value .interactive-response-placeholder-content { color: var(--vscode-editorGhostText-foreground); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); margin-bottom: 16px; } @@ -2682,7 +2688,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-used-context-list .monaco-list { border: none; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); width: auto; } @@ -2694,8 +2700,8 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container .chat-resource-widget, .interactive-item-container .chat-agent-widget .monaco-button { - border-radius: 4px; - padding: 1px 3px; + border-radius: var(--vscode-cornerRadius-small); + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40); } .interactive-item-container .chat-agent-command { @@ -2704,8 +2710,8 @@ have to be updated for changes to the rules above, or to support more deeply nes display: inline-flex; align-items: center; margin-right: 0.5ch; - border-radius: 4px; - padding: 0 0 0 3px; + border-radius: var(--vscode-cornerRadius-small); + padding: 0 0 0 var(--vscode-spacing-size40); } .interactive-item-container .chat-agent-command > .monaco-button { @@ -2715,8 +2721,8 @@ have to be updated for changes to the rules above, or to support more deeply nes cursor: pointer; padding: 0 2px; margin-left: 2px; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; + border-top-right-radius: var(--vscode-cornerRadius-small); + border-bottom-right-radius: var(--vscode-cornerRadius-small); } .interactive-item-container .chat-agent-command > .monaco-button:hover { @@ -2730,9 +2736,9 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Chat artifacts widget — collapsible list of session artifacts */ .chat-artifacts-widget { - padding: 4px 3px 4px 3px; + padding: var(--vscode-spacing-size40); box-sizing: border-box; - border: 1px solid var(--vscode-input-border, transparent); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); background-color: var(--vscode-editor-background); border-bottom: none; border-radius: var(--chat-input-stack-radius-top, var(--vscode-cornerRadius-large)) var(--chat-input-stack-radius-top, var(--vscode-cornerRadius-large)) 0 0; @@ -2765,11 +2771,11 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section { - padding-left: 3px; + padding-left: var(--vscode-spacing-size40); display: flex; align-items: center; flex: 1; - font-size: 12px; + font-size: var(--vscode-fontSize-label1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -2777,10 +2783,10 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section .codicon { - font-size: 16px; + font-size: var(--vscode-codiconFontSize); line-height: 22px; flex-shrink: 0; - margin-right: 3px; + margin-right: var(--vscode-spacing-size40); } .chat-artifacts-widget .chat-artifacts-list { @@ -2807,7 +2813,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-artifacts-widget .chat-artifacts-list-icon { - font-size: 14px; + font-size: var(--vscode-codiconFontSize-compact); display: flex; align-items: center; } @@ -2816,7 +2822,7 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; + font-size: var(--vscode-fontSize-body1); flex-shrink: 1; min-width: 0; } @@ -2825,7 +2831,7 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 12px; + font-size: var(--vscode-fontSize-label1); opacity: 0.7; margin-left: 6px; flex-shrink: 0; @@ -2848,7 +2854,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-artifacts-widget .chat-artifacts-list-actions .action-item .action-label { padding: 2px; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); cursor: pointer; } @@ -2861,30 +2867,38 @@ have to be updated for changes to the rules above, or to support more deeply nes flex-direction: column; flex-wrap: wrap; align-items: center; - border-radius: 4px; - border: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-small); + border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); .chat-view-changes-icon { - padding: 3px; + box-sizing: border-box; + width: 22px; + height: 22px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; float: right; cursor: pointer; } .chat-view-changes-icon:hover { - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-small); background-color: var(--vscode-toolbar-hoverBackground); } .insertions-and-deletions { display: flex; - margin-right: 5px; - font-size: 12px; + margin-right: var(--vscode-spacing-size60); + font-size: var(--vscode-fontSize-label1); } .checkpoint-file-changes-summary-header { - padding: 3px 3px 3px 3px; + min-height: 22px; + padding: 0 var(--vscode-spacing-size40); width: 100%; display: flex; + align-items: center; box-sizing: border-box; justify-content: space-between; } @@ -2906,7 +2920,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .checkpoint-file-changes-summary-header .chat-file-changes-label .monaco-button .codicon { - font-size: 16px; + font-size: var(--vscode-codiconFontSize); } .chat-summary-list { @@ -2926,19 +2940,19 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-summary-list .monaco-scrollable-element { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .insertions { color: var(--vscode-chat-linesAddedForeground); - font-weight: bold; - padding-left: 5px; - padding-right: 5px; + font-weight: var(--vscode-fontWeight-semiBold); + padding-left: var(--vscode-spacing-size60); + padding-right: var(--vscode-spacing-size60); } .deletions { color: var(--vscode-chat-linesRemovedForeground); - font-weight: bold; + font-weight: var(--vscode-fontWeight-semiBold); } } @@ -3024,7 +3038,7 @@ have to be updated for changes to the rules above, or to support more deeply nes border-radius: var(--vscode-cornerRadius-small); color: var(--vscode-descriptionForeground); font-size: var(--vscode-chat-font-size-body-s); - font-weight: normal; + font-weight: var(--vscode-fontWeight-regular); line-height: 1.5em; cursor: pointer; list-style: none; @@ -3066,7 +3080,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .insertions, .deletions { - font-weight: normal; + font-weight: var(--vscode-fontWeight-regular); padding: var(--vscode-spacing-sizeNone); } @@ -3272,22 +3286,22 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-used-context-list, .chat-quota-error-widget, .chat-rate-limited-widget { - border: 1px solid var(--vscode-chat-requestBorder); - border-radius: 4px; + border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-small); margin-bottom: 8px; } .interactive-response-progress-tree, .interactive-session .chat-summary-list, .interactive-session .chat-used-context-list { - padding: 4px 3px; + padding: var(--vscode-spacing-size40); .monaco-icon-label { - padding: 0px 3px; + padding: 0 var(--vscode-spacing-size40); gap: 2px; &::before { - width: var(--vscode-chat-font-size-body-s); + width: var(--vscode-codiconFontSize); padding-right: 0; } } @@ -3296,7 +3310,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-editing-session-list { .monaco-icon-label { - padding: 0px 3px; + padding: 0 var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40); } .monaco-icon-label.excluded { @@ -3309,7 +3323,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-summary-list .monaco-list .monaco-list-row { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .interactive-session .chat-summary-list .monaco-list .monaco-list-row:hover { @@ -3317,7 +3331,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-used-context-list .monaco-list .monaco-list-row { - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); } .interactive-session .chat-file-changes-label { @@ -3325,7 +3339,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-used-context-label { - font-size: 13px; + font-size: var(--vscode-fontSize-body1); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); user-select: none; @@ -3348,7 +3362,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-used-context-label .monaco-button { width: fit-content; border: none; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); gap: 0; text-align: initial; justify-content: initial; @@ -3391,12 +3405,12 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-file-changes-label .monaco-text-button:focus-visible, .interactive-session .chat-used-context-label .monaco-text-button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); } .interactive-session .chat-file-changes-label .monaco-button .codicon, .interactive-session .chat-used-context-label .monaco-button .codicon { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); color: var(--vscode-icon-foreground) !important; } @@ -3411,7 +3425,7 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Hover chevron indicator for collapsible parts */ .chat-collapsible-hover-chevron { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); opacity: 0; transform: rotate(0deg); transform-origin: center; @@ -3445,18 +3459,18 @@ have to be updated for changes to the rules above, or to support more deeply nes display: flex; align-items: center; gap: 4px; - margin: 0 0 14px 0; - font-size: 13px; + margin: 0 0 var(--vscode-spacing-size160) 0; + font-size: var(--vscode-fontSize-body1); /* Tool calls transition from a progress to a collapsible list part, which needs to have this top padding. The working progress also can be replaced by a tool progress part. So align this padding so the text doesn't appear to shift. */ padding-top: 2px; > .codicon[class*='codicon-'] { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); &::before { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); } } @@ -3556,7 +3570,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container .chat-command-button .monaco-button .codicon { margin-left: 0; - margin-top: 1px; + margin-top: var(--vscode-spacing-size20); } .chat-code-citation-label { @@ -3621,7 +3635,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-attached-context-hover .chat-attached-context-url-separator { - border-top: 1px solid var(--vscode-chat-requestBorder); + border-top: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); left: 0; right: 0; position: absolute; @@ -3650,7 +3664,7 @@ have to be updated for changes to the rules above, or to support more deeply nes max-height: 500px; box-sizing: border-box; padding-right: 10px; - padding-bottom: 15px; + padding-bottom: var(--vscode-spacing-size160); } .chat-element-hover .chat-element-hover-section { @@ -3658,7 +3672,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-element-hover .chat-element-hover-section + .chat-element-hover-section { - border-top: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.2)); + border-top: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.2)); } .chat-element-hover .chat-element-hover-screenshot .chat-attached-context-hover { @@ -3682,8 +3696,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-element-hover .chat-element-hover-header { - font-size: 11px; - font-weight: 600; + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-semiBold); text-transform: uppercase; color: var(--vscode-descriptionForeground); letter-spacing: 0.5px; @@ -3694,9 +3708,9 @@ have to be updated for changes to the rules above, or to support more deeply nes margin: 0; padding: 4px 6px; background: var(--vscode-textCodeBlock-background); - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-medium); font-family: var(--monaco-monospace-font); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); line-height: 1.5; white-space: pre; overflow: hidden; @@ -3724,14 +3738,14 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-element-hover .chat-element-hover-label { font-family: var(--monaco-monospace-font); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); color: var(--vscode-debugTokenExpression-name); white-space: nowrap; } .chat-element-hover .chat-element-hover-value { font-family: var(--monaco-monospace-font); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); color: var(--vscode-editor-foreground); word-break: break-all; display: flex; @@ -3743,14 +3757,14 @@ have to be updated for changes to the rules above, or to support more deeply nes display: inline-block; width: 12px; height: 12px; - border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.4)); - border-radius: 2px; + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.4)); + border-radius: var(--vscode-cornerRadius-xSmall); flex-shrink: 0; } .chat-element-hover .chat-element-hover-text { font-family: var(--monaco-monospace-font); - font-size: 12px; + font-size: var(--vscode-fontSize-label1); color: var(--vscode-editor-foreground); white-space: pre-wrap; word-break: break-word; @@ -3763,7 +3777,7 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-top: 6px; color: var(--vscode-textLink-foreground); cursor: pointer; - font-size: 12px; + font-size: var(--vscode-fontSize-label1); text-decoration: none; } @@ -3774,19 +3788,19 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-attached-context-attachment .chat-attached-context-pill { - font-size: 12px; + font-size: var(--vscode-fontSize-label1); display: inline-flex; align-items: center; padding: 2px 0 2px 0px; - border-radius: 2px; - margin-right: 1px; + border-radius: var(--vscode-cornerRadius-xSmall); + margin-right: var(--vscode-spacing-size20); user-select: none; outline: none; border: none; .codicon.codicon-file-media, .codicon.codicon-warning { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); margin-right: 2px; } } @@ -3800,9 +3814,9 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-attached-context-attachment .chat-attached-context-pill-image { width: 13px; height: 13px; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); object-fit: cover; - margin-right: 3px; + margin-right: var(--vscode-spacing-size40); } .chat-attached-context-attachment .chat-attached-context-custom-text { @@ -3841,7 +3855,7 @@ have to be updated for changes to the rules above, or to support more deeply nes box-sizing: border-box; width: 88px; height: 88px; - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); padding: 0; background-color: var(--vscode-input-background); border-color: var(--vscode-chat-requestBorder, var(--vscode-input-border, transparent)); @@ -3859,7 +3873,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment .chat-attached-context-pill-image { width: 100%; height: 100%; - border-radius: 7px; + border-radius: calc(var(--vscode-cornerRadius-large) - var(--vscode-strokeThickness)); margin: 0; object-fit: cover; } @@ -3973,7 +3987,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session:not(.chat-widget > .interactive-session) { .interactive-item-container { - padding: 5px 16px; + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size160); } .interactive-item-container.interactive-request { @@ -4001,12 +4015,12 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container.interactive-request .value .rendered-markdown { background-color: var(--vscode-chat-requestBubbleBackground); - border-radius: var(--vscode-cornerRadius-xLarge); + border-radius: var(--vscode-cornerRadius-medium); padding: 8px 12px; max-width: 90%; margin-left: auto; width: fit-content; - margin-bottom: 5px; + margin-bottom: var(--vscode-spacing-size60); position: relative; } @@ -4039,7 +4053,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .hc-black .interactive-item-container.interactive-request .value .rendered-markdown, .hc-light .interactive-item-container.interactive-request .value .rendered-markdown { - border: 1px dotted var(--vscode-focusBorder); + border: var(--vscode-strokeThickness) dotted var(--vscode-focusBorder); } .interactive-item-container.interactive-request .value .rendered-markdown > :first-child { @@ -4082,7 +4096,7 @@ have to be updated for changes to the rules above, or to support more deeply nes width: fit-content; justify-content: flex-end; margin-left: auto; - padding-bottom: 5px; + padding-bottom: var(--vscode-spacing-size60); } .interactive-item-container.interactive-request .value > .chat-request-attachment-cards { @@ -4143,7 +4157,7 @@ have to be updated for changes to the rules above, or to support more deeply nes justify-content: center; background: transparent; color: var(--vscode-descriptionForeground); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); z-index: 1; transition: opacity 80ms ease-out; } @@ -4162,7 +4176,7 @@ have to be updated for changes to the rules above, or to support more deeply nes box-sizing: border-box; width: 88px; height: 88px; - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); padding: 0; background-color: var(--vscode-input-background); border-color: var(--vscode-chat-requestBorder, var(--vscode-input-border, transparent)); @@ -4195,7 +4209,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment .chat-attached-context-pill-image { width: 100%; height: 100%; - border-radius: 7px; + border-radius: calc(var(--vscode-cornerRadius-large) - var(--vscode-strokeThickness)); margin: 0; object-fit: cover; } @@ -4216,11 +4230,11 @@ have to be updated for changes to the rules above, or to support more deeply nes min-width: 28px !important; margin: 0 !important; padding: 0 !important; - border-radius: 50%; + border-radius: var(--vscode-cornerRadius-circle); background-color: var(--vscode-input-background); color: var(--vscode-foreground); - border: 1px solid var(--vscode-input-border, var(--vscode-contrastBorder, transparent)); - box-shadow: 0 1px 4px rgb(0 0 0 / 45%); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-contrastBorder, transparent)); + box-shadow: 0 1px 4px var(--vscode-widget-shadow); align-items: center; justify-content: center; transition: opacity 80ms ease-out; @@ -4228,7 +4242,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment .chat-attached-context-download-button .codicon { color: var(--vscode-foreground) !important; - font-size: 16px; + font-size: var(--vscode-codiconFontSize); margin: 0 !important; } @@ -4258,9 +4272,9 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-item-container .header .detail .codicon-check { - margin-right: 7px; + margin-right: var(--vscode-spacing-size80); vertical-align: middle; - font-size: 11px; + font-size: var(--vscode-codiconFontSize-compact); display: none; } @@ -4292,10 +4306,10 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; z-index: 100; background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background)); - border: 1px solid var(--vscode-chat-requestBorder); + border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); top: -13px; right: 20px; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-large); height: 26px; } @@ -4316,13 +4330,18 @@ have to be updated for changes to the rules above, or to support more deeply nes } .request-hover.expanded .actions-container { - padding: 0 3px; + padding: 0 var(--vscode-spacing-size40); } .request-hover:not(.expanded) .actions-container { .action-label { + box-sizing: border-box; + width: 22px; + height: 22px; margin: 4px 2px 0; - padding: 3px 3px; + padding: 0; + align-items: center; + justify-content: center; } } @@ -4344,7 +4363,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .checkpoint-line-left, .checkpoint-line-right { flex: 1; - height: 1px; + height: var(--vscode-strokeThickness); background-color: var(--vscode-chat-requestBorder, var(--vscode-input-background)); } @@ -4367,18 +4386,21 @@ have to be updated for changes to the rules above, or to support more deeply nes } .monaco-toolbar .action-label { - font-size: 12px; + box-sizing: border-box; + width: fit-content; + min-width: 22px; + height: 22px; + font-size: var(--vscode-fontSize-label1); line-height: 18px; color: var(--vscode-descriptionForeground); - border: 1px solid transparent; + border: var(--vscode-strokeThickness) solid transparent; background-color: transparent; - padding: 1px 5px; - margin-right: 5px; + padding: 0 var(--vscode-spacing-size60); + margin-right: var(--vscode-spacing-size60); } .monaco-toolbar .action-label.codicon.codicon-repo-forked { - width: fit-content; - padding: 2px 5px; + padding: 0 var(--vscode-spacing-size60); } .monaco-toolbar .actions-container > .action-item:last-child .action-label { @@ -4393,7 +4415,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-toolbar .actions-container > .action-item:not(:first-child):has(.action-label.codicon.codicon-repo-forked)::before { content: '\00B7'; - font-size: 12px; + font-size: var(--vscode-fontSize-label1); line-height: 18px; color: var(--vscode-descriptionForeground); } @@ -4407,7 +4429,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-toolbar .action-item.chat-restore-checkpoint-item { display: flex; align-items: stretch; - margin-left: 5px; + margin-left: var(--vscode-spacing-size60); } .monaco-toolbar .action-item.chat-restore-checkpoint-item.confirming { @@ -4415,7 +4437,7 @@ have to be updated for changes to the rules above, or to support more deeply nes background-color: var(--vscode-sideBar-background); border-radius: var(--vscode-cornerRadius-medium); overflow: hidden; - margin-right: 5px; + margin-right: var(--vscode-spacing-size60); } .monaco-toolbar .action-item.chat-restore-checkpoint-item.confirming .action-label:first-child, @@ -4429,7 +4451,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-toolbar .action-item.chat-restore-checkpoint-item.confirming .action-label:first-child { align-items: center; - padding: 1px 10px; + padding: 0 var(--vscode-spacing-size100); background: linear-gradient(90deg, var(--vscode-foreground) 0%, var(--vscode-foreground) 30%, @@ -4469,7 +4491,7 @@ have to be updated for changes to the rules above, or to support more deeply nes align-self: stretch; margin-right: 0; margin-left: 0; - padding: 0 5px; + padding: 0 var(--vscode-spacing-size60); } .monaco-toolbar .action-label.chat-restore-checkpoint-cancel.hidden { @@ -4486,18 +4508,20 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-top: 10px; .checkpoint-label-text { - font-size: 12px; + box-sizing: border-box; + height: 22px; + font-size: var(--vscode-fontSize-label1); line-height: 18px; color: var(--vscode-descriptionForeground); display: flex; align-items: center; flex-shrink: 0; - padding: 1px 5px; - border: 1px solid transparent; + padding: 0 var(--vscode-spacing-size60); + border: var(--vscode-strokeThickness) solid transparent; } .checkpoint-dot-separator { - font-size: 12px; + font-size: var(--vscode-fontSize-label1); line-height: 18px; color: var(--vscode-descriptionForeground); flex-shrink: 0; @@ -4527,7 +4551,7 @@ have to be updated for changes to the rules above, or to support more deeply nes div[data-index="0"] .monaco-tl-contents { .interactive-item-container.interactive-request:not(.editing) { - padding-top: 19px; + padding-top: var(--vscode-spacing-size200); } .request-hover { @@ -4539,7 +4563,7 @@ have to be updated for changes to the rules above, or to support more deeply nes outline: none !important; .interactive-item-container .value .rendered-markdown { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); } } @@ -4549,7 +4573,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-request.editing-input .rendered-markdown { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); } .interactive-request.editing { @@ -4579,7 +4603,7 @@ have to be updated for changes to the rules above, or to support more deeply nes margin: 8px 0; & .monaco-button.monaco-dropdown-button { - padding: 0 3px; + padding: 0 var(--vscode-spacing-size40); } } @@ -4664,8 +4688,8 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 0 4px 0 2px; gap: 4px; cursor: pointer; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; + border-top-right-radius: var(--vscode-cornerRadius-small); + border-bottom-right-radius: var(--vscode-cornerRadius-small); box-sizing: border-box; } @@ -4674,13 +4698,13 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-welcome-view-suggested-prompt > .chat-suggest-next-dropdown:focus { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: -1px; } /* Chevron icon in dropdown container */ .chat-welcome-view-suggested-prompt .codicon-chevron-down.dropdown-chevron { - font-size: 12px; + font-size: var(--vscode-codiconFontSize-compact); opacity: 0.7; flex-shrink: 0; } @@ -4692,11 +4716,11 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Vertical separator between label and chevron in suggested next actions */ .chat-suggest-next-dropdown > .chat-suggest-next-separator { - width: 1px; + width: var(--vscode-strokeThickness); height: 16px; background-color: currentColor; opacity: 0.5; - border-radius: 1px; + border-radius: var(--vscode-cornerRadius-circle); align-self: center; flex-shrink: 0; } @@ -4713,7 +4737,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-attachments-show-more-button:focus { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: -1px; } @@ -4788,7 +4812,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container.pending-divider .pending-divider-label { font-size: var(--vscode-chat-font-size-body-xs); - font-weight: 500; + font-weight: var(--vscode-fontWeight-semiBold); text-transform: uppercase; letter-spacing: 0.5px; color: var(--vscode-descriptionForeground); From 9ee20aedfd300b64508f90ee9eb64c465237472a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:07:31 +1000 Subject: [PATCH 13/29] Replace Automation icon with spinner while an automation is running (#330921) * Initial plan * Render automation status in leading icon Co-authored-by: benvillalobos <4691428+benvillalobos@users.noreply.github.com> * Fix automation status icon alignment Center the leading icon slot so spinner and status glyphs align with the Automations label. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benvillalobos <4691428+benvillalobos@users.noreply.github.com> Co-authored-by: Ben Villalobos Copilot-Session: f2973122-684b-46c8-a1df-586695137bca --- .../sessions/browser/media/sessionsList.css | 8 +-- .../sessions/browser/views/sessionsList.ts | 23 +++---- .../test/browser/sessionsList.test.ts | 61 +++++++++++++++++++ 3 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index a276f88703bea..1a42235b226dd 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -499,18 +499,12 @@ .session-section-icon { flex-shrink: 0; margin-right: 6px; - font-size: var(--vscode-codiconFontSize, 16px); - } - - .session-section-status-indicator { - flex-shrink: 0; - margin-left: 4px; - position: relative; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; + font-size: var(--vscode-codiconFontSize, 16px); } .session-section-count { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 115b3cea2b155..18bcec1f5727f 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -921,7 +921,6 @@ function renderSessionHeaderToolbar(template: ISessionHeaderTemplate, element interface ISessionSectionTemplate extends ISessionHeaderTemplate { readonly container: HTMLElement; readonly icon: HTMLElement; - readonly statusIndicator: HTMLElement; readonly label: HTMLElement; readonly count: HTMLElement; readonly chevron: HTMLElement; @@ -996,8 +995,6 @@ export class SessionSectionRenderer implements ITreeRenderer, _index: number, template: ISessionSectionTemplate): void { @@ -1061,6 +1058,9 @@ export class SessionSectionRenderer implements ITreeRenderer { const automationStatus = this.automationStatus.read(reader); if (automationStatus === SessionStatus.NeedsInput) { - template.statusIndicator.style.display = ''; + template.icon.className = 'session-section-icon'; statusIcon.setStatus(SessionStatus.NeedsInput, true, false); } else if (automationStatus === SessionStatus.InProgress) { - template.statusIndicator.style.display = ''; + template.icon.className = 'session-section-icon'; statusIcon.setStatus(SessionStatus.InProgress, true, false); } else if (automationStatus === SessionStatus.Completed) { - template.statusIndicator.style.display = ''; + template.icon.className = 'session-section-icon'; statusIcon.setStatus(SessionStatus.Completed, false, false); } else { - template.statusIndicator.style.display = 'none'; + statusIcon.reset(); + template.icon.className = `session-section-icon ${ThemeIcon.asClassName(Codicon.watch)}`; } })); - } else { - template.statusIndicator.style.display = 'none'; - DOM.clearNode(template.statusIndicator); } template.label.textContent = element.label; diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 5dd49f4e5b3d7..28861b2245a72 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -10,6 +10,8 @@ import { constObservable, observableValue } from '../../../../../base/common/obs 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 { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { IMenuService } from '../../../../../platform/actions/common/actions.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -22,6 +24,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla import { IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; +import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; @@ -122,6 +125,64 @@ suite('Sessions - SessionsList', () => { assert.deepStrictEqual(selectedSections, [section]); }); + test('renders in-progress automation status in the leading icon slot', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(MenuWorkbenchToolBar, new class extends mock() { + override set context(_context: unknown) { } + override dispose(): void { } + }); + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return false; } + }()); + instantiationService.stub(ISessionsListModelService, new class extends mock() { }); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const automationService = new class extends mock() { + override readonly runs = constObservable([{ + id: 'pending', + automationId: 'automation', + status: 'pending', + trigger: 'schedule', + startedAt: '2026-08-14T00:00:00.000Z', + leaderWindowId: 1, + }]); + }; + const renderer = new SessionSectionRenderer( + true, + () => { }, + instantiationService, + contextKeyService, + automationService, + constObservable([]), + new class extends mock() { + override readonly extUri = new ExtUri(() => true); + }, + new class extends mock() { + override readonly activeCustomView = constObservable(undefined); + }, + new class extends mock() { }, + ); + const container = document.createElement('div'); + const template = renderer.renderTemplate(container); + disposables.add(template.disposables); + + renderer.renderElement(upcastPartial[0]>({ + element: { id: 'automations', label: 'Automations', sessions: [] }, + collapsible: false, + collapsed: false, + }), 0, template); + + const spinner = container.querySelector('.monaco-pixel-spinner'); + assert.deepStrictEqual({ + watchIcon: !!container.querySelector('.session-section-icon.codicon-watch'), + spinnerParent: spinner?.parentElement?.className, + trailingStatusIndicator: !!container.querySelector('.session-section-status-indicator'), + }, { + watchIcon: false, + spinnerParent: 'session-section-icon', + trailingStatusIndicator: false, + }); + }); + test('derives terminal automation status from the supplied session snapshot', () => { const session = createSession('automation', { isRead: false, From f44c690a259c64265be3f8ea323834daec4a8d45 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:23:10 -1000 Subject: [PATCH 14/29] Bump xterm to 6.1.0-beta.302 (#331830) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 96 ++++++++++++++++++------------------ package.json | 20 ++++---- remote/package-lock.json | 96 ++++++++++++++++++------------------ remote/package.json | 20 ++++---- remote/web/package-lock.json | 88 ++++++++++++++++----------------- remote/web/package.json | 18 +++---- 6 files changed, 169 insertions(+), 169 deletions(-) diff --git a/package-lock.json b/package-lock.json index 53691fbc1a704..2c53ca845ce75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,16 +43,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.292", - "@xterm/addon-image": "^0.10.0-beta.292", - "@xterm/addon-ligatures": "^0.11.0-beta.292", - "@xterm/addon-progress": "^0.3.0-beta.292", - "@xterm/addon-search": "^0.17.0-beta.292", - "@xterm/addon-serialize": "^0.15.0-beta.292", - "@xterm/addon-unicode11": "^0.10.0-beta.292", - "@xterm/addon-webgl": "^0.20.0-beta.291", - "@xterm/headless": "^6.1.0-beta.292", - "@xterm/xterm": "^6.1.0-beta.292", + "@xterm/addon-clipboard": "^0.3.0-beta.301", + "@xterm/addon-image": "^0.10.0-beta.299", + "@xterm/addon-ligatures": "^0.11.0-beta.299", + "@xterm/addon-progress": "^0.3.0-beta.299", + "@xterm/addon-search": "^0.17.0-beta.299", + "@xterm/addon-serialize": "^0.15.0-beta.299", + "@xterm/addon-unicode11": "^0.10.0-beta.299", + "@xterm/addon-webgl": "^0.20.0-beta.298", + "@xterm/headless": "^6.1.0-beta.301", + "@xterm/xterm": "^6.1.0-beta.302", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "foundry-local-sdk": "1.2.3", @@ -5227,27 +5227,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.292.tgz", - "integrity": "sha512-UNjPtLHO9do8lDoYcfuipBtQzjjex2iOudN7G+2ExqcvecVnex6v0UGxUttgg0xN+a6TTEZkdIaWIi/kZnjtoA==", + "version": "0.3.0-beta.301", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", + "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.292.tgz", - "integrity": "sha512-kFhxNiZ5eU1acMYvp/aS0wYs2wJ3j/ueq54FSNnWwnchirOuAVQxxdlFdxlZFHVLSas7rijqaRN1dniN+30HGQ==", + "version": "0.10.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", + "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.292.tgz", - "integrity": "sha512-C9DaNn/E1SJh+gULgtT8Js2fz/8QIJ1LvmDXsMMayzvrL72g1kvFOYZSXAcmYveID3eIOIQKyecStfvFR7HU6A==", + "version": "0.11.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", + "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -5257,7 +5257,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -5270,63 +5270,63 @@ } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.292.tgz", - "integrity": "sha512-KJl9JCXoc9r8a+7M0oQa9RQHthBXo+Ng90Avdnd0GajmCPJKHushC3J0o2+wPMnS8qz8Bj3rkG0BX/EnY1+9yQ==", + "version": "0.3.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", + "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.292.tgz", - "integrity": "sha512-F12brJvBETblxh7NGu9RmHwcavcJJvd9RWjPID+WZg9mJcFucDjZTOSrWZyWRPCn/+8NWadsFAQynFFmBMo2Ng==", + "version": "0.17.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", + "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.292.tgz", - "integrity": "sha512-hmjC5eYDlJBhhMQQtTZrSsnvrGiSo4cJuToeRwafmIl1ei773DP5+xiJh4A7IHg6EozzEUcMprO50ehPS9gAmg==", + "version": "0.15.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", + "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.292.tgz", - "integrity": "sha512-2Qw4ET+eq+rP07khdPkjwZd60+QZ8i5zI2iiVZGlghSvRtuujgrhXeYf1B5Pu4KNpmrOnZERLOyeoH/VW5xV4g==", + "version": "0.10.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", + "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.291", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz", - "integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==", + "version": "0.20.0-beta.298", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", + "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.292.tgz", - "integrity": "sha512-Es2xZ95wJb8n3hIfTfEh4AYzf7wXVQX1dVdA6idFFjRm26p+x6LZEgurXD6ZPwwsbXSfJXcAuzhYQruSw9Ccgg==", + "version": "6.1.0-beta.301", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.301.tgz", + "integrity": "sha512-ApQUwq3BlHA8xlOeBKnC+1l+g95JHVl9bg7EujtUBZA9aKtweyOF9PEd/1larB0c4UF5XAf9lWjj+ICNZNR/1w==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz", - "integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==", + "version": "6.1.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", + "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index 317255c0d13c5..3c518289b1eb2 100644 --- a/package.json +++ b/package.json @@ -132,16 +132,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.292", - "@xterm/addon-image": "^0.10.0-beta.292", - "@xterm/addon-ligatures": "^0.11.0-beta.292", - "@xterm/addon-progress": "^0.3.0-beta.292", - "@xterm/addon-search": "^0.17.0-beta.292", - "@xterm/addon-serialize": "^0.15.0-beta.292", - "@xterm/addon-unicode11": "^0.10.0-beta.292", - "@xterm/addon-webgl": "^0.20.0-beta.291", - "@xterm/headless": "^6.1.0-beta.292", - "@xterm/xterm": "^6.1.0-beta.292", + "@xterm/addon-clipboard": "^0.3.0-beta.301", + "@xterm/addon-image": "^0.10.0-beta.299", + "@xterm/addon-ligatures": "^0.11.0-beta.299", + "@xterm/addon-progress": "^0.3.0-beta.299", + "@xterm/addon-search": "^0.17.0-beta.299", + "@xterm/addon-serialize": "^0.15.0-beta.299", + "@xterm/addon-unicode11": "^0.10.0-beta.299", + "@xterm/addon-webgl": "^0.20.0-beta.298", + "@xterm/headless": "^6.1.0-beta.301", + "@xterm/xterm": "^6.1.0-beta.302", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "foundry-local-sdk": "1.2.3", diff --git a/remote/package-lock.json b/remote/package-lock.json index f1a5c864cbd35..0d791e25e1bdf 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -28,16 +28,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.292", - "@xterm/addon-image": "^0.10.0-beta.292", - "@xterm/addon-ligatures": "^0.11.0-beta.292", - "@xterm/addon-progress": "^0.3.0-beta.292", - "@xterm/addon-search": "^0.17.0-beta.292", - "@xterm/addon-serialize": "^0.15.0-beta.292", - "@xterm/addon-unicode11": "^0.10.0-beta.292", - "@xterm/addon-webgl": "^0.20.0-beta.291", - "@xterm/headless": "^6.1.0-beta.292", - "@xterm/xterm": "^6.1.0-beta.292", + "@xterm/addon-clipboard": "^0.3.0-beta.301", + "@xterm/addon-image": "^0.10.0-beta.299", + "@xterm/addon-ligatures": "^0.11.0-beta.299", + "@xterm/addon-progress": "^0.3.0-beta.299", + "@xterm/addon-search": "^0.17.0-beta.299", + "@xterm/addon-serialize": "^0.15.0-beta.299", + "@xterm/addon-unicode11": "^0.10.0-beta.299", + "@xterm/addon-webgl": "^0.20.0-beta.298", + "@xterm/headless": "^6.1.0-beta.301", + "@xterm/xterm": "^6.1.0-beta.302", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -1241,27 +1241,27 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.292.tgz", - "integrity": "sha512-UNjPtLHO9do8lDoYcfuipBtQzjjex2iOudN7G+2ExqcvecVnex6v0UGxUttgg0xN+a6TTEZkdIaWIi/kZnjtoA==", + "version": "0.3.0-beta.301", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", + "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.292.tgz", - "integrity": "sha512-kFhxNiZ5eU1acMYvp/aS0wYs2wJ3j/ueq54FSNnWwnchirOuAVQxxdlFdxlZFHVLSas7rijqaRN1dniN+30HGQ==", + "version": "0.10.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", + "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.292.tgz", - "integrity": "sha512-C9DaNn/E1SJh+gULgtT8Js2fz/8QIJ1LvmDXsMMayzvrL72g1kvFOYZSXAcmYveID3eIOIQKyecStfvFR7HU6A==", + "version": "0.11.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", + "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -1271,67 +1271,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.292.tgz", - "integrity": "sha512-KJl9JCXoc9r8a+7M0oQa9RQHthBXo+Ng90Avdnd0GajmCPJKHushC3J0o2+wPMnS8qz8Bj3rkG0BX/EnY1+9yQ==", + "version": "0.3.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", + "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.292.tgz", - "integrity": "sha512-F12brJvBETblxh7NGu9RmHwcavcJJvd9RWjPID+WZg9mJcFucDjZTOSrWZyWRPCn/+8NWadsFAQynFFmBMo2Ng==", + "version": "0.17.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", + "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.292.tgz", - "integrity": "sha512-hmjC5eYDlJBhhMQQtTZrSsnvrGiSo4cJuToeRwafmIl1ei773DP5+xiJh4A7IHg6EozzEUcMprO50ehPS9gAmg==", + "version": "0.15.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", + "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.292.tgz", - "integrity": "sha512-2Qw4ET+eq+rP07khdPkjwZd60+QZ8i5zI2iiVZGlghSvRtuujgrhXeYf1B5Pu4KNpmrOnZERLOyeoH/VW5xV4g==", + "version": "0.10.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", + "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.291", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz", - "integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==", + "version": "0.20.0-beta.298", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", + "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.292.tgz", - "integrity": "sha512-Es2xZ95wJb8n3hIfTfEh4AYzf7wXVQX1dVdA6idFFjRm26p+x6LZEgurXD6ZPwwsbXSfJXcAuzhYQruSw9Ccgg==", + "version": "6.1.0-beta.301", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.301.tgz", + "integrity": "sha512-ApQUwq3BlHA8xlOeBKnC+1l+g95JHVl9bg7EujtUBZA9aKtweyOF9PEd/1larB0c4UF5XAf9lWjj+ICNZNR/1w==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz", - "integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==", + "version": "6.1.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", + "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index 1011b29dcc829..d53cd5c6bad45 100644 --- a/remote/package.json +++ b/remote/package.json @@ -23,16 +23,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.292", - "@xterm/addon-image": "^0.10.0-beta.292", - "@xterm/addon-ligatures": "^0.11.0-beta.292", - "@xterm/addon-progress": "^0.3.0-beta.292", - "@xterm/addon-search": "^0.17.0-beta.292", - "@xterm/addon-serialize": "^0.15.0-beta.292", - "@xterm/addon-unicode11": "^0.10.0-beta.292", - "@xterm/addon-webgl": "^0.20.0-beta.291", - "@xterm/headless": "^6.1.0-beta.292", - "@xterm/xterm": "^6.1.0-beta.292", + "@xterm/addon-clipboard": "^0.3.0-beta.301", + "@xterm/addon-image": "^0.10.0-beta.299", + "@xterm/addon-ligatures": "^0.11.0-beta.299", + "@xterm/addon-progress": "^0.3.0-beta.299", + "@xterm/addon-search": "^0.17.0-beta.299", + "@xterm/addon-serialize": "^0.15.0-beta.299", + "@xterm/addon-unicode11": "^0.10.0-beta.299", + "@xterm/addon-webgl": "^0.20.0-beta.298", + "@xterm/headless": "^6.1.0-beta.301", + "@xterm/xterm": "^6.1.0-beta.302", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index becf0ef843005..c98679ac2f54f 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.292", - "@xterm/addon-image": "^0.10.0-beta.292", - "@xterm/addon-ligatures": "^0.11.0-beta.292", - "@xterm/addon-progress": "^0.3.0-beta.292", - "@xterm/addon-search": "^0.17.0-beta.292", - "@xterm/addon-serialize": "^0.15.0-beta.292", - "@xterm/addon-unicode11": "^0.10.0-beta.292", - "@xterm/addon-webgl": "^0.20.0-beta.291", - "@xterm/xterm": "^6.1.0-beta.292", + "@xterm/addon-clipboard": "^0.3.0-beta.301", + "@xterm/addon-image": "^0.10.0-beta.299", + "@xterm/addon-ligatures": "^0.11.0-beta.299", + "@xterm/addon-progress": "^0.3.0-beta.299", + "@xterm/addon-search": "^0.17.0-beta.299", + "@xterm/addon-serialize": "^0.15.0-beta.299", + "@xterm/addon-unicode11": "^0.10.0-beta.299", + "@xterm/addon-webgl": "^0.20.0-beta.298", + "@xterm/xterm": "^6.1.0-beta.302", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.4.3", @@ -100,27 +100,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.292.tgz", - "integrity": "sha512-UNjPtLHO9do8lDoYcfuipBtQzjjex2iOudN7G+2ExqcvecVnex6v0UGxUttgg0xN+a6TTEZkdIaWIi/kZnjtoA==", + "version": "0.3.0-beta.301", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", + "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.292.tgz", - "integrity": "sha512-kFhxNiZ5eU1acMYvp/aS0wYs2wJ3j/ueq54FSNnWwnchirOuAVQxxdlFdxlZFHVLSas7rijqaRN1dniN+30HGQ==", + "version": "0.10.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", + "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.292.tgz", - "integrity": "sha512-C9DaNn/E1SJh+gULgtT8Js2fz/8QIJ1LvmDXsMMayzvrL72g1kvFOYZSXAcmYveID3eIOIQKyecStfvFR7HU6A==", + "version": "0.11.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", + "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -130,58 +130,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.292.tgz", - "integrity": "sha512-KJl9JCXoc9r8a+7M0oQa9RQHthBXo+Ng90Avdnd0GajmCPJKHushC3J0o2+wPMnS8qz8Bj3rkG0BX/EnY1+9yQ==", + "version": "0.3.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", + "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.292.tgz", - "integrity": "sha512-F12brJvBETblxh7NGu9RmHwcavcJJvd9RWjPID+WZg9mJcFucDjZTOSrWZyWRPCn/+8NWadsFAQynFFmBMo2Ng==", + "version": "0.17.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", + "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.292.tgz", - "integrity": "sha512-hmjC5eYDlJBhhMQQtTZrSsnvrGiSo4cJuToeRwafmIl1ei773DP5+xiJh4A7IHg6EozzEUcMprO50ehPS9gAmg==", + "version": "0.15.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", + "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.292.tgz", - "integrity": "sha512-2Qw4ET+eq+rP07khdPkjwZd60+QZ8i5zI2iiVZGlghSvRtuujgrhXeYf1B5Pu4KNpmrOnZERLOyeoH/VW5xV4g==", + "version": "0.10.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", + "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.291", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz", - "integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==", + "version": "0.20.0-beta.298", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", + "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.292" + "@xterm/xterm": "^6.1.0-beta.301" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.292", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz", - "integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==", + "version": "6.1.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", + "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 7da53e358296b..6ebeca9775a87 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.292", - "@xterm/addon-image": "^0.10.0-beta.292", - "@xterm/addon-ligatures": "^0.11.0-beta.292", - "@xterm/addon-progress": "^0.3.0-beta.292", - "@xterm/addon-search": "^0.17.0-beta.292", - "@xterm/addon-serialize": "^0.15.0-beta.292", - "@xterm/addon-unicode11": "^0.10.0-beta.292", - "@xterm/addon-webgl": "^0.20.0-beta.291", - "@xterm/xterm": "^6.1.0-beta.292", + "@xterm/addon-clipboard": "^0.3.0-beta.301", + "@xterm/addon-image": "^0.10.0-beta.299", + "@xterm/addon-ligatures": "^0.11.0-beta.299", + "@xterm/addon-progress": "^0.3.0-beta.299", + "@xterm/addon-search": "^0.17.0-beta.299", + "@xterm/addon-serialize": "^0.15.0-beta.299", + "@xterm/addon-unicode11": "^0.10.0-beta.299", + "@xterm/addon-webgl": "^0.20.0-beta.298", + "@xterm/xterm": "^6.1.0-beta.302", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.4.3", From 816148459bf0621f7c617273c578057db65d7c79 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 21:08:49 +0200 Subject: [PATCH 15/29] sessions: make changes diffs responsive (#331851) * sessions: make changes diffs responsive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: share responsive diff preference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: rename diff editor options service Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: clarify responsive diff accessibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../multiDiffEditor/multiDiffEditorWidget.ts | 16 ++- .../multiDiffEditorWidgetImpl.ts | 10 +- .../widget/multiDiffEditorWidget.test.ts | 16 ++- .../changes/browser/changesViewActions.ts | 21 +-- .../changes/browser/sessionChangesEditor.ts | 15 +-- .../sessionsChangesAccessibilityHelp.ts | 2 +- .../test/browser/changesViewActions.test.ts | 21 +-- .../diffEditor.sessions.contribution.ts | 64 +++++++--- .../browser/diffEditorOptionsService.ts | 35 +++++ .../editor/common/diffEditorOptionsService.ts | 19 +++ .../diffEditor.sessions.contribution.test.ts | 120 +++++++++--------- .../browser/diffEditorOptionsService.test.ts | 45 +++++++ 12 files changed, 259 insertions(+), 125 deletions(-) create mode 100644 src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts create mode 100644 src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts create mode 100644 src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 0a17dd5fb810d..7be2b56210e9f 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -26,7 +26,7 @@ import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; export class MultiDiffEditorWidget extends Disposable { private readonly _dimension = observableValue(this, undefined); private readonly _viewModel = observableValue(this, undefined); - private readonly _renderSideBySide = observableValue(this, undefined); + private readonly _diffLayoutOptions = observableValue(this, undefined); private readonly _widgetImpl = derived(this, (reader) => { readHotReloadableExport(DiffEditorItemTemplate, reader); @@ -36,7 +36,7 @@ export class MultiDiffEditorWidget extends Disposable { this._dimension, this._viewModel, this._workbenchUIElementFactory, - this._renderSideBySide, + this._diffLayoutOptions, this._diffEditorOptions, )); }); @@ -98,14 +98,18 @@ export class MultiDiffEditorWidget extends Disposable { /** * Overrides whether the embedded diffs render side by side (`true`) or inline * (`false`) as editor-local state, independent of the - * `diffEditor.renderSideBySide` setting. When left unset the setting applies. + * `diffEditor.renderSideBySide` setting. Responsive inline fallback is disabled + * unless explicitly enabled. */ - public setRenderSideBySide(renderSideBySide: boolean): void { - this._renderSideBySide.set(renderSideBySide, undefined); + public setRenderSideBySide(renderSideBySide: boolean, options?: { readonly useInlineViewWhenSpaceIsLimited?: boolean }): void { + this._diffLayoutOptions.set({ + renderSideBySide, + useInlineViewWhenSpaceIsLimited: options?.useInlineViewWhenSpaceIsLimited ?? false, + }, undefined); } public toggleRenderSideBySide(): void { - this._renderSideBySide.set(!(this._renderSideBySide.get() ?? true), undefined); + this.setRenderSideBySide(!(this._diffLayoutOptions.get()?.renderSideBySide ?? true)); } private readonly _activeControl = derived(this, (reader) => this._widgetImpl.read(reader).activeControl.read(reader)); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 8e93b4f8962b8..002a646054cbf 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -77,7 +77,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _dimension: IObservable, private readonly _viewModel: IObservable, private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, - private readonly _renderSideBySide: IObservable, + private readonly _diffLayoutOptions: IObservable, private readonly _diffEditorOptions: IDiffEditorOptions | undefined, @IContextKeyService private readonly _parentContextKeyService: IContextKeyService, @IInstantiationService private readonly _parentInstantiationService: IInstantiationService, @@ -108,11 +108,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { ]); this._sizeObserver = this._register(new ObservableElementSizeObserver(this._element, undefined)); this._optionsOverride = derived(this, reader => { - const renderSideBySide = this._renderSideBySide.read(reader); - // Also pin `useInlineViewWhenSpaceIsLimited` off so the toggle deterministically - // controls inline vs. side-by-side regardless of the available width. - const options: IDiffEditorOptions = renderSideBySide === undefined ? {} : { renderSideBySide, useInlineViewWhenSpaceIsLimited: false }; - return { ...this._diffEditorOptions, ...options }; + return { ...this._diffEditorOptions, ...this._diffLayoutOptions.read(reader) }; }); this._objectPool = this._register(new ObjectPool((data) => { const template = this._instantiationService.createInstance( @@ -191,7 +187,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const ctxRenderSideBySide = this._parentContextKeyService.createKey(EditorContextKeys.multiDiffEditorRenderSideBySide.key, true); this._register(autorun((reader) => { - const renderSideBySide = this._renderSideBySide.read(reader); + const renderSideBySide = this._diffLayoutOptions.read(reader)?.renderSideBySide; if (renderSideBySide !== undefined) { ctxRenderSideBySide.set(renderSideBySide); } diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index f6859bb4eabdd..1dc0a859a3057 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -37,7 +37,7 @@ suite('MultiDiffEditorWidget', () => { sinon.restore(); }); - test('applies document options before attaching the diff model', async () => { + test('applies document and responsive layout options before attaching the diff model', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); services.set(IActionViewItemService, new NullActionViewItemService()); @@ -78,6 +78,7 @@ suite('MultiDiffEditorWidget', () => { {} satisfies IWorkbenchUIElementFactory, undefined, ); + widget.setRenderSideBySide(true, { useInlineViewWhenSpaceIsLimited: true }); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); await waitForState(viewModel.items, items => items.length === 1); @@ -85,12 +86,23 @@ suite('MultiDiffEditorWidget', () => { widget.reveal({ original: originalUri, modified: modifiedUri }, { highlight: false }); try { + const activeControl = widget.getActiveControl(); + const renderSideBySideWhenNarrow = activeControl?.renderSideBySide; + widget.layout(new Dimension(1000, 600)); assert.deepStrictEqual({ configuredAccessibilitySupport: updateOptionsSpy.firstCall.args[0].accessibilitySupport, + configuredRenderSideBySide: updateOptionsSpy.firstCall.args[0].renderSideBySide, + configuredUseInlineViewWhenSpaceIsLimited: updateOptionsSpy.firstCall.args[0].useInlineViewWhenSpaceIsLimited, + renderSideBySideWhenNarrow, + renderSideBySideWhenWide: activeControl?.renderSideBySide, optionsAppliedBeforeModel: updateOptionsSpy.calledBefore(setDiffModelSpy), - effectiveAccessibilitySupport: widget.getActiveControl()?.getModifiedEditor().getOption(EditorOption.accessibilitySupport), + effectiveAccessibilitySupport: activeControl?.getModifiedEditor().getOption(EditorOption.accessibilitySupport), }, { configuredAccessibilitySupport: 'off', + configuredRenderSideBySide: true, + configuredUseInlineViewWhenSpaceIsLimited: true, + renderSideBySideWhenNarrow: false, + renderSideBySideWhenWide: true, optionsAppliedBeforeModel: true, effectiveAccessibilitySupport: AccessibilitySupport.Disabled, }); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index 4bdc5cd1d2a72..d00c461da5819 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -8,7 +8,6 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { observableFromEvent } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -29,6 +28,7 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; import { ActiveSessionContextKeys, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; import { IChangesViewService } from '../common/changesViewService.js'; +import { SessionsDiffRenderSideBySideContext } from '../../editor/common/diffEditorOptionsService.js'; import { CHANGES_HEADER_ACTIONS_ID } from './changesView.js'; import { SessionChangesEditor } from './sessionChangesEditor.js'; @@ -325,22 +325,23 @@ registerAction2(ExpandAllSessionChangesDiffsAction); // The Agents window reuses the workbench `toggle.diff.renderSideBySide` command so a // user's keybinding for it carries over here (issue #324765). The sessions override of -// IDiffEditorCommandsService flips the workspace `diffEditor.renderSideBySide` setting, -// which the Changes editor observes. +// IDiffEditorCommandsService updates the Changes editor's own preferred layout. -// Primary header button with state-specific titles: "Show Side by Side Diff" when -// currently inline, and (checked) "Show Inline Diff" when currently side by side. +// The action changes the preferred layout. Side by side still falls back to inline +// when the editor is narrow, so the label must not promise an immediate layout. MenuRegistry.appendMenuItem(Menus.SessionsEditorHeaderSecondary, { command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, - title: localize('showSideBySideDiff', "Show Side by Side Diff"), + title: localize('preferSideBySideDiff', "Prefer Side by Side Diff"), + tooltip: localize('preferSideBySideDiff.tooltip', "Uses inline layout when space is limited unless screen reader optimized mode is enabled."), icon: Codicon.diffSidebyside, toggled: { condition: ContextKeyExpr.or( - ContextKeyExpr.and(singlePaneChangesEditorActive, EditorContextKeys.multiDiffEditorRenderSideBySide), - ContextKeyExpr.and(singlePaneFileDiffEditorActive, EditorContextKeys.diffEditorInlineMode.negate()) + ContextKeyExpr.and(singlePaneChangesEditorActive, SessionsDiffRenderSideBySideContext), + ContextKeyExpr.and(singlePaneFileDiffEditorActive, SessionsDiffRenderSideBySideContext) )!, - title: localize('showInlineDiff', "Show Inline Diff"), + title: localize('preferInlineDiff', "Prefer Inline Diff"), + tooltip: localize('preferInlineDiff.tooltip', "Always uses inline layout."), }, }, group: '1_diff', @@ -352,7 +353,7 @@ MenuRegistry.appendMenuItem(Menus.SessionsEditorHeaderSecondary, { MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, - title: localize2('toggleDiffView', "Toggle Diff View"), + title: localize2('togglePreferredDiffView', "Toggle Preferred Diff View"), category: localize2('changes', "Changes"), }, when: singlePaneDiffEditorTitleVisible diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 82d5a1d70897e..68c5a9996a09e 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -13,7 +13,6 @@ import { URI } from '../../../../base/common/uri.js'; import { IDiffEditor } from '../../../../editor/common/editorCommon.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; @@ -52,6 +51,7 @@ import { CheckboxActionViewItem } from '../../../../base/browser/ui/toggle/toggl import { defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { localize } from '../../../../nls.js'; import { getChangesEditorFileStats } from './changesEditorLabels.js'; +import { IDiffEditorOptionsService } from '../../editor/common/diffEditorOptionsService.js'; const HEADER_HEIGHT = 35; @@ -199,9 +199,9 @@ export class SessionChangesEditor extends AbstractEditorWithViewState { - if (e.affectsConfiguration('diffEditor.renderSideBySide')) { - this._applyRenderSideBySide(); - } + this._register(autorun(reader => { + this.widget?.setRenderSideBySide(this.diffEditorOptionsService.renderSideBySide.read(reader), { useInlineViewWhenSpaceIsLimited: true }); })); } - private _applyRenderSideBySide(): void { - this.widget?.setRenderSideBySide(this.configurationService.getValue('diffEditor.renderSideBySide') ?? true); - } - /** * Resolves the diff editor and code editor showing the given file, mirroring * {@link MultiDiffEditor.tryGetCodeEditor} so file-toolbar actions can operate diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts index 216af23194597..6d6c973ae4a84 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -35,7 +35,7 @@ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplemen 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.diffView', "File diffs can be shown side by side or inline. Use the Toggle Diff View command to switch between them{0}.", '')); + content.push(localize('sessionsChanges.diffView', "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 the Toggle Preferred Diff View command to switch the preference{0}.", '')); return new AccessibleContentProvider( AccessibleViewProviderId.SessionsChanges, 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 a4f14b2c7bc21..833b374f7d3e5 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -16,6 +16,7 @@ import { Context } from '../../../../../platform/contextkey/browser/contextKeySe import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; +import { SessionsDiffRenderSideBySideContext } from '../../../editor/common/diffEditorOptionsService.js'; import { ActiveEditorContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext, TextCompareEditorActiveContext } from '../../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -155,7 +156,7 @@ suite('Changes View Actions', () => { }); }); - test('toggle inline view is contributed to multi-file and single-file diff editor headers with toggle state', () => { + test('preferred diff view is contributed to multi-file and single-file diff editor headers with toggle state', () => { const item = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderSecondary) .filter(isIMenuItem) .find(item => item.command.id === 'toggle.diff.renderSideBySide'); @@ -177,9 +178,10 @@ suite('Changes View Actions', () => { group: item.group, order: item.order, icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + tooltip: typeof item.command.tooltip === 'string' ? item.command.tooltip : item.command.tooltip?.value, toggledTitle: toggledInfo?.title, - toggledOnMultiDiffSideBySide: toggledInfo?.condition.serialize().includes(EditorContextKeys.multiDiffEditorRenderSideBySide.key), - toggledOnSingleDiffSideBySide: toggledInfo?.condition.serialize().includes(EditorContextKeys.diffEditorInlineMode.key), + toggledTooltip: toggledInfo?.tooltip, + toggledOnSharedPreference: toggledInfo?.condition.serialize().includes(SessionsDiffRenderSideBySideContext.key), hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), hasTextCompareEditorGate: when.includes(TextCompareEditorActiveContext.key), @@ -188,13 +190,14 @@ suite('Changes View Actions', () => { matchesNonTextDiffContext: item.when?.evaluate(nonTextDiffContext) ?? false, }, { id: 'toggle.diff.renderSideBySide', - title: 'Show Side by Side Diff', + title: 'Prefer Side by Side Diff', group: '1_diff', order: 20, icon: Codicon.diffSidebyside.id, - toggledTitle: 'Show Inline Diff', - toggledOnMultiDiffSideBySide: true, - toggledOnSingleDiffSideBySide: true, + tooltip: 'Uses inline layout when space is limited unless screen reader optimized mode is enabled.', + toggledTitle: 'Prefer Inline Diff', + toggledTooltip: 'Always uses inline layout.', + toggledOnSharedPreference: true, hasSessionsWindowGate: true, hasActiveEditorGate: true, hasTextCompareEditorGate: true, @@ -204,7 +207,7 @@ suite('Changes View Actions', () => { }); }); - test('toggle inline view is contributed to the command palette (Changes category)', () => { + test('preferred diff view is contributed to the command palette (Changes category)', () => { const item = MenuRegistry.getMenuItems(MenuId.CommandPalette) .filter(isIMenuItem) .find(item => item.command.id === 'toggle.diff.renderSideBySide' && item.command.category !== undefined && (typeof item.command.category === 'string' ? item.command.category : item.command.category.value) === 'Changes'); @@ -222,7 +225,7 @@ suite('Changes View Actions', () => { hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), }, { id: 'toggle.diff.renderSideBySide', - title: 'Toggle Diff View', + title: 'Toggle Preferred Diff View', category: 'Changes', hasSessionsWindowGate: true, hasActiveEditorGate: true, diff --git a/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts b/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts index b9d277d7622ac..79c7e05be4ce2 100644 --- a/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts @@ -5,30 +5,30 @@ import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; import { isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; -import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { DiffEditorCommandsService, IDiffEditorCommandsService } from '../../../../workbench/browser/parts/editor/diffEditorCommandsService.js'; import { TextDiffEditor } from '../../../../workbench/browser/parts/editor/textDiffEditor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { SessionChangesEditor } from '../../changes/browser/sessionChangesEditor.js'; +import { IDiffEditorOptionsService } from '../common/diffEditorOptionsService.js'; +import { DiffEditorOptionsService } from './diffEditorOptionsService.js'; -/** - * Agents window implementation that also drives the multi-diff Changes editor. Unlike a single - * diff editor, it has no single modified resource, so the render mode is toggled via the - * workspace `diffEditor.renderSideBySide` setting, which the Changes editor observes. - */ +/** Drives the shared preferred diff layout for supported editors in the Agents window. */ export class SessionsDiffEditorCommandsService extends DiffEditorCommandsService { constructor( @IEditorService editorService: IEditorService, - @ITextResourceConfigurationService private readonly sessionsTextResourceConfigurationService: ITextResourceConfigurationService, + @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, - @IConfigurationService private readonly configurationService: IConfigurationService, + @IDiffEditorOptionsService private readonly diffEditorOptionsService: IDiffEditorOptionsService, ) { - super(editorService, sessionsTextResourceConfigurationService, contextKeyService); + super(editorService, textResourceConfigurationService, contextKeyService); } override async toggleRenderSideBySide(args: unknown[]): Promise { @@ -49,26 +49,18 @@ export class SessionsDiffEditorCommandsService extends DiffEditorCommandsService continue; } - const renderSideBySide = !control.renderSideBySide; - if (modifiedResource) { - await this.sessionsTextResourceConfigurationService.updateValue(modifiedResource, 'diffEditor.renderSideBySide', renderSideBySide); - } - control.updateOptions({ renderSideBySide, useInlineViewWhenSpaceIsLimited: false }); + this.diffEditorOptionsService.toggleRenderSideBySide(); return; } } if (this.editorService.activeEditorPane instanceof SessionChangesEditor) { - const key = 'diffEditor.renderSideBySide'; - const value = this.configurationService.getValue(key) ?? true; - await this.configurationService.updateValue(key, !value, ConfigurationTarget.WORKSPACE); + this.diffEditorOptionsService.toggleRenderSideBySide(); return; } if (resource) { - const key = 'diffEditor.renderSideBySide'; - const value = this.sessionsTextResourceConfigurationService.getValue(resource, key); - await this.sessionsTextResourceConfigurationService.updateValue(resource, key, !value); + this.diffEditorOptionsService.toggleRenderSideBySide(); return; } @@ -76,4 +68,36 @@ export class SessionsDiffEditorCommandsService extends DiffEditorCommandsService } } +export class SessionsDiffEditorLayoutContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.diffEditorLayout'; + + constructor( + @IEditorService private readonly editorService: IEditorService, + @IDiffEditorOptionsService private readonly diffEditorOptionsService: IDiffEditorOptionsService, + ) { + super(); + this._register(this.editorService.onDidActiveEditorChange(() => this.applyLayout())); + this._register(this.editorService.onDidVisibleEditorsChange(() => this.applyLayout())); + this._register(autorun(reader => { + this.diffEditorOptionsService.renderSideBySide.read(reader); + this.applyLayout(); + })); + } + + private applyLayout(): void { + const renderSideBySide = this.diffEditorOptionsService.renderSideBySide.get(); + for (const pane of new Set([this.editorService.activeEditorPane, ...this.editorService.visibleEditorPanes])) { + if (pane instanceof TextDiffEditor) { + const control = pane.getControl(); + if (isDiffEditor(control)) { + control.updateOptions({ renderSideBySide, useInlineViewWhenSpaceIsLimited: true }); + } + } + } + } +} + +registerSingleton(IDiffEditorOptionsService, DiffEditorOptionsService, InstantiationType.Delayed); registerSingleton(IDiffEditorCommandsService, SessionsDiffEditorCommandsService, InstantiationType.Delayed); +registerWorkbenchContribution2(SessionsDiffEditorLayoutContribution.ID, SessionsDiffEditorLayoutContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts b/src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts new file mode 100644 index 0000000000000..024d24cd0b59b --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IDiffEditorOptionsService, SessionsDiffRenderSideBySideContext } from '../common/diffEditorOptionsService.js'; + +const PREFERRED_RENDER_SIDE_BY_SIDE_STORAGE_KEY = 'sessions.diffEditor.renderSideBySide'; + +export class DiffEditorOptionsService extends Disposable implements IDiffEditorOptionsService { + + declare readonly _serviceBrand: undefined; + + readonly renderSideBySide; + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + this.renderSideBySide = observableValue(this, storageService.getBoolean(PREFERRED_RENDER_SIDE_BY_SIDE_STORAGE_KEY, StorageScope.PROFILE, true)); + this._register(bindContextKey(SessionsDiffRenderSideBySideContext, contextKeyService, reader => this.renderSideBySide.read(reader))); + } + + toggleRenderSideBySide(): void { + const renderSideBySide = !this.renderSideBySide.get(); + this.renderSideBySide.set(renderSideBySide, undefined); + this.storageService.store(PREFERRED_RENDER_SIDE_BY_SIDE_STORAGE_KEY, renderSideBySide, StorageScope.PROFILE, StorageTarget.USER); + } +} diff --git a/src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts b/src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts new file mode 100644 index 0000000000000..1f1c1120f7cde --- /dev/null +++ b/src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IDiffEditorOptionsService = createDecorator('diffEditorOptionsService'); + +export interface IDiffEditorOptionsService { + readonly _serviceBrand: undefined; + readonly renderSideBySide: IObservable; + toggleRenderSideBySide(): void; +} + +export const SessionsDiffRenderSideBySideContext = new RawContextKey('sessionsDiffRenderSideBySide', true, localize('sessionsDiffRenderSideBySide', "Whether Agents window diffs prefer side-by-side layout")); diff --git a/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts index 6019dcab644e8..526adffd29544 100644 --- a/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts @@ -4,57 +4,45 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.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 { ConfigurationTarget, IConfigurationOverrides, IConfigurationUpdateOverrides, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ITextResourceConfigurationService } from '../../../../../editor/common/services/textResourceConfiguration.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IEditorPane, IVisibleEditorPane } from '../../../../../workbench/common/editor.js'; import { SessionChangesEditor } from '../../../changes/browser/sessionChangesEditor.js'; -import { SessionsDiffEditorCommandsService } from '../../browser/diffEditor.sessions.contribution.js'; +import { SessionsDiffEditorCommandsService, SessionsDiffEditorLayoutContribution } from '../../browser/diffEditor.sessions.contribution.js'; import { TextDiffEditor } from '../../../../../workbench/browser/parts/editor/textDiffEditor.js'; import { IDiffEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; import { ICodeEditor, IDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; import { EditorType } from '../../../../../editor/common/editorCommon.js'; +import { IDiffEditorOptionsService } from '../../common/diffEditorOptionsService.js'; suite('SessionsDiffEditorCommandsService', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - interface IWrite { readonly resource?: URI; readonly key: string; readonly value: unknown; readonly target?: ConfigurationTarget } - - function createService(activeEditorPane: IEditorPane | undefined, renderSideBySide: boolean, visibleEditorPanes: readonly IVisibleEditorPane[] = []): { service: SessionsDiffEditorCommandsService; workspaceWrites: IWrite[]; resourceWrites: IWrite[] } { - const workspaceWrites: IWrite[] = []; - const resourceWrites: IWrite[] = []; + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + function createService(activeEditorPane: IEditorPane | undefined, visibleEditorPanes: readonly IVisibleEditorPane[] = []): { service: SessionsDiffEditorCommandsService; getToggleCount(): number } { const editorService = new class extends mock() { override get activeEditorPane() { return activeEditorPane as IVisibleEditorPane | undefined; } override get activeEditor() { return undefined; } override get visibleEditorPanes() { return visibleEditorPanes; } override get visibleEditors() { return []; } }; - const configurationService = new class extends mock() { - override getValue(arg1?: string | IConfigurationOverrides): T { return renderSideBySide as unknown as T; } - override updateValue(key: string, value: unknown, arg3?: ConfigurationTarget | IConfigurationOverrides | IConfigurationUpdateOverrides): Promise { - workspaceWrites.push({ key, value, target: arg3 as ConfigurationTarget }); - return Promise.resolve(); - } - }; - const textResourceConfigurationService = new class extends mock() { - override getValue(): T { return true as unknown as T; } - override updateValue(resource: URI | undefined, key: string, value: unknown): Promise { - resourceWrites.push({ resource, key, value }); - return Promise.resolve(); - } - }; + const textResourceConfigurationService = new class extends mock() { }; const contextKeyService = new class extends mock() { override getContextKeyValue(): T | undefined { return undefined; } }; + let toggleCount = 0; + const diffEditorOptionsService = new class extends mock() { + override toggleRenderSideBySide(): void { toggleCount++; } + }; - const service = new SessionsDiffEditorCommandsService(editorService, textResourceConfigurationService, contextKeyService, configurationService); - return { service, workspaceWrites, resourceWrites }; + const service = new SessionsDiffEditorCommandsService(editorService, textResourceConfigurationService, contextKeyService, diffEditorOptionsService); + return { service, getToggleCount: () => toggleCount }; } function createTextDiffEditor(resource: URI, renderSideBySide: boolean, controlUpdates: IDiffEditorOptions[]): TextDiffEditor { @@ -74,43 +62,25 @@ suite('SessionsDiffEditorCommandsService', () => { return pane; } - test('flips the workspace renderSideBySide setting when the Changes editor is active', async () => { + test('toggles the shared preference from the Changes editor', async () => { // Use the prototype so `instanceof SessionChangesEditor` holds without constructing the heavy pane. const changesEditor = Object.create(SessionChangesEditor.prototype) as IEditorPane; - const { service, workspaceWrites, resourceWrites } = createService(changesEditor, true /* currently side by side */); + const { service, getToggleCount } = createService(changesEditor); await service.toggleRenderSideBySide([]); - assert.deepStrictEqual(workspaceWrites, [{ key: 'diffEditor.renderSideBySide', value: false, target: ConfigurationTarget.WORKSPACE }]); - assert.strictEqual(resourceWrites.length, 0, 'the base resource-scoped path must not be used for the Changes editor'); + assert.strictEqual(getToggleCount(), 1); }); - test('toggles back to side by side when currently inline', async () => { - const changesEditor = Object.create(SessionChangesEditor.prototype) as IEditorPane; - const { service, workspaceWrites } = createService(changesEditor, false /* currently inline */); - - await service.toggleRenderSideBySide([]); - - assert.deepStrictEqual(workspaceWrites, [{ key: 'diffEditor.renderSideBySide', value: true, target: ConfigurationTarget.WORKSPACE }]); - }); - - test('toggles and persists the active single-file diff editor without forwarded arguments', async () => { + test('toggles the shared preference when a narrow single-file diff is effectively inline', async () => { const resource = URI.file('/workspace/file.ts'); const controlUpdates: IDiffEditorOptions[] = []; const textDiffEditor = createTextDiffEditor(resource, false, controlUpdates); - const { service, workspaceWrites, resourceWrites } = createService(textDiffEditor, true); + const { service, getToggleCount } = createService(textDiffEditor); await service.toggleRenderSideBySide([]); - assert.deepStrictEqual({ - workspaceWrites, - resourceWrites, - controlUpdates, - }, { - workspaceWrites: [], - resourceWrites: [{ resource, key: 'diffEditor.renderSideBySide', value: true }], - controlUpdates: [{ renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false }], - }); + assert.deepStrictEqual({ toggleCount: getToggleCount(), controlUpdates }, { toggleCount: 1, controlUpdates: [] }); }); test('toggles the visible single-file diff matching the forwarded resource', async () => { @@ -120,18 +90,18 @@ suite('SessionsDiffEditorCommandsService', () => { const targetControlUpdates: IDiffEditorOptions[] = []; const activeEditor = createTextDiffEditor(activeResource, true, activeControlUpdates); const targetEditor = createTextDiffEditor(targetResource, true, targetControlUpdates); - const { service, resourceWrites } = createService(activeEditor, true, [targetEditor as IVisibleEditorPane]); + const { service, getToggleCount } = createService(activeEditor, [targetEditor as IVisibleEditorPane]); await service.toggleRenderSideBySide([targetResource]); assert.deepStrictEqual({ - resourceWrites, + toggleCount: getToggleCount(), activeControlUpdates, targetControlUpdates, }, { - resourceWrites: [{ resource: targetResource, key: 'diffEditor.renderSideBySide', value: false }], + toggleCount: 1, activeControlUpdates: [], - targetControlUpdates: [{ renderSideBySide: false, useInlineViewWhenSpaceIsLimited: false }], + targetControlUpdates: [], }); }); @@ -140,18 +110,50 @@ suite('SessionsDiffEditorCommandsService', () => { const controlUpdates: IDiffEditorOptions[] = []; const targetEditor = createTextDiffEditor(resource, true, controlUpdates); const changesEditor = Object.create(SessionChangesEditor.prototype) as IEditorPane; - const { service, workspaceWrites, resourceWrites } = createService(changesEditor, true, [targetEditor as IVisibleEditorPane]); + const { service, getToggleCount } = createService(changesEditor, [targetEditor as IVisibleEditorPane]); await service.toggleRenderSideBySide([resource]); assert.deepStrictEqual({ - workspaceWrites, - resourceWrites, + toggleCount: getToggleCount(), controlUpdates, }, { - workspaceWrites: [], - resourceWrites: [{ resource, key: 'diffEditor.renderSideBySide', value: false }], - controlUpdates: [{ renderSideBySide: false, useInlineViewWhenSpaceIsLimited: false }], + toggleCount: 1, + controlUpdates: [], + }); + }); + + test('applies the shared responsive preference to all visible text diffs', () => { + const activeControlUpdates: IDiffEditorOptions[] = []; + const visibleControlUpdates: IDiffEditorOptions[] = []; + const activeEditor = createTextDiffEditor(URI.file('/workspace/active.ts'), false, activeControlUpdates); + const visibleEditor = createTextDiffEditor(URI.file('/workspace/visible.ts'), false, visibleControlUpdates); + const editorService = new class extends mock() { + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidVisibleEditorsChange = Event.None; + override get activeEditorPane() { return activeEditor as IVisibleEditorPane; } + override get visibleEditorPanes() { return [visibleEditor as IVisibleEditorPane]; } + }; + const renderSideBySide = observableValue('test', true); + const diffEditorOptionsService = new class extends mock() { + override readonly renderSideBySide = renderSideBySide; + }; + disposables.add(new SessionsDiffEditorLayoutContribution(editorService, diffEditorOptionsService)); + + renderSideBySide.set(false, undefined); + + assert.deepStrictEqual({ + activeControlUpdates, + visibleControlUpdates, + }, { + activeControlUpdates: [ + { renderSideBySide: true, useInlineViewWhenSpaceIsLimited: true }, + { renderSideBySide: false, useInlineViewWhenSpaceIsLimited: true }, + ], + visibleControlUpdates: [ + { renderSideBySide: true, useInlineViewWhenSpaceIsLimited: true }, + { renderSideBySide: false, useInlineViewWhenSpaceIsLimited: true }, + ], }); }); }); diff --git a/src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts b/src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts new file mode 100644 index 0000000000000..86db04f84c08c --- /dev/null +++ b/src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { InMemoryStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { SessionsDiffRenderSideBySideContext } from '../../common/diffEditorOptionsService.js'; +import { DiffEditorOptionsService } from '../../browser/diffEditorOptionsService.js'; + +suite('DiffEditorOptionsService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('defaults to responsive side by side and persists the shared preference', () => { + const storageService = disposables.add(new InMemoryStorageService()); + const contextKeyService = disposables.add(new MockContextKeyService()); + const service = disposables.add(new DiffEditorOptionsService(storageService, contextKeyService)); + + const initial = { + renderSideBySide: service.renderSideBySide.get(), + contextValue: contextKeyService.getContextKeyValue(SessionsDiffRenderSideBySideContext.key), + storedValue: storageService.getBoolean('sessions.diffEditor.renderSideBySide', StorageScope.PROFILE), + }; + service.toggleRenderSideBySide(); + + assert.deepStrictEqual({ + initial, + renderSideBySide: service.renderSideBySide.get(), + contextValue: contextKeyService.getContextKeyValue(SessionsDiffRenderSideBySideContext.key), + storedValue: storageService.getBoolean('sessions.diffEditor.renderSideBySide', StorageScope.PROFILE), + }, { + initial: { + renderSideBySide: true, + contextValue: true, + storedValue: undefined, + }, + renderSideBySide: false, + contextValue: false, + storedValue: false, + }); + }); +}); From 023440219af120cfd5419d3be530810b5942bf98 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Thu, 20 Aug 2026 15:34:04 -0400 Subject: [PATCH 16/29] Add experiment driven inline model feedback survey (#331850) * Add experiment driven inline model feedback survey Adds a survey that can be attached to chat responses through an experiment treatment, so a survey can be authored, changed, or retired without shipping code. When one applies, a combined thumbs up and down control replaces the usual helpful and unhelpful actions in the response footer and opens a short multi step survey beneath it. The survey is fully described by a versioned JSON payload: which responses it applies to, when it may open on its own, and its steps. Answers are reported as each step is taken, so surveys the user abandons still produce data, and they land in GitHub restricted telemetry through a command the Copilot extension registers. Manual activation is never rate limited. The pacing rules in the payload govern only surfacing the user did not ask for: a weekly cooldown, a per session cap, a probability that ramps with use, and a trigger for switching off the surveyed model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aaf67de5-2dc8-437c-961b-257c488475a7 * Fix fixture services and address review feedback Registers the survey service with the shared chat fixture services, which the component fixture tests build a real chat list renderer from. Without it the resize observer harness fixtures failed to load. Also from review: - Re-check a cached survey against the current config and the feedback setting, so retiring a treatment or turning feedback off takes effect. A survey the user is part way through is left alone, since a treatment that briefly resolves to nothing must not take a form away mid answer. - Release per session state when a session is disposed, rather than holding it until the treatment changes or the window closes. - Move focus to the close button after submitting, so keyboard users are not left on the document body while the acknowledgement is showing. - Report the feedback control as expanded rather than pressed, since it discloses a panel rather than holding a state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aaf67de5-2dc8-437c-961b-257c488475a7 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aaf67de5-2dc8-437c-961b-257c488475a7 --- extensions/copilot/package.json | 1 + .../extension/vscode/contributions.ts | 2 + ...hatModelFeedbackSurveyForwardingContrib.ts | 119 ++++ src/vs/base/browser/ui/toolbar/toolbar.ts | 5 +- .../actions/chatModelFeedbackSurveyActions.ts | 163 +++++ .../chat/browser/actions/chatTitleActions.ts | 4 +- .../chat/browser/chat.shared.contribution.ts | 7 + ...atModelFeedbackSurveyPromptContribution.ts | 72 ++ .../chatModelFeedbackSurveyService.ts | 673 ++++++++++++++++++ .../chatModelFeedbackSurveyWidget.ts | 291 ++++++++ .../media/chatModelFeedbackSurvey.css | 160 +++++ .../chat/browser/widget/chatListRenderer.ts | 12 +- .../chat/common/actions/chatContextKeys.ts | 2 + .../chatModelFeedbackSurveyConfig.ts | 568 +++++++++++++++ .../chatModelFeedbackSurveyTelemetry.ts | 49 ++ .../chat/common/model/chatViewModel.ts | 2 + .../chatModelFeedbackSurveyActions.test.ts | 40 ++ ...elFeedbackSurveyPromptContribution.test.ts | 106 +++ .../chatModelFeedbackSurveyService.test.ts | 551 ++++++++++++++ .../chatModelFeedbackSurveyWidget.test.ts | 252 +++++++ .../mockChatModelFeedbackSurveyService.ts | 29 + .../browser/widget/chatListRenderer.test.ts | 10 + .../browser/widget/chatListWidget.test.ts | 3 + .../chatModelFeedbackSurveyConfig.test.ts | 184 +++++ .../chat/chatFixtureUtils.ts | 3 + 25 files changed, 3303 insertions(+), 5 deletions(-) create mode 100644 extensions/copilot/src/extension/telemetry/vscode/chatModelFeedbackSurveyForwardingContrib.ts create mode 100644 src/vs/workbench/contrib/chat/browser/actions/chatModelFeedbackSurveyActions.ts create mode 100644 src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.ts create mode 100644 src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts create mode 100644 src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts create mode 100644 src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css create mode 100644 src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts create mode 100644 src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyActions.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyService.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/mockChatModelFeedbackSurveyService.ts create mode 100644 src/vs/workbench/contrib/chat/test/common/feedbackSurvey/chatModelFeedbackSurveyConfig.test.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index cc394aedb47b1..576eb3fcab982 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -81,6 +81,7 @@ "onStartupFinished", "onLanguageModelChat:copilot", "onUri", + "onCommand:_github.copilot.chat.reportModelFeedbackSurvey", "onFileSystem:ccreq", "onFileSystem:ccsettings" ], diff --git a/extensions/copilot/src/extension/extension/vscode/contributions.ts b/extensions/copilot/src/extension/extension/vscode/contributions.ts index 8bc63ff84e017..4a021168ce635 100644 --- a/extensions/copilot/src/extension/extension/vscode/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode/contributions.ts @@ -8,6 +8,7 @@ import { asContributionFactory, IExtensionContributionFactory } from '../../comm import * as contextContribution from '../../context/vscode/context.contribution'; import { LifecycleTelemetryContrib } from '../../telemetry/common/lifecycleTelemetryContrib'; import { GithubTelemetryForwardingContrib } from '../../telemetry/vscode/githubTelemetryForwardingContrib'; +import { ChatModelFeedbackSurveyForwardingContrib } from '../../telemetry/vscode/chatModelFeedbackSurveyForwardingContrib'; // ############################################################################### // ### ### @@ -21,6 +22,7 @@ const vscodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(LifecycleTelemetryContrib), asContributionFactory(NesActivationTelemetryContribution), asContributionFactory(GithubTelemetryForwardingContrib), + asContributionFactory(ChatModelFeedbackSurveyForwardingContrib), contextContribution, ]; diff --git a/extensions/copilot/src/extension/telemetry/vscode/chatModelFeedbackSurveyForwardingContrib.ts b/extensions/copilot/src/extension/telemetry/vscode/chatModelFeedbackSurveyForwardingContrib.ts new file mode 100644 index 0000000000000..87dcfc7e06969 --- /dev/null +++ b/extensions/copilot/src/extension/telemetry/vscode/chatModelFeedbackSurveyForwardingContrib.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { commands } from 'vscode'; +import { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../platform/telemetry/common/telemetry'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IExtensionContribution } from '../../common/contributions'; + +/** + * Command the workbench invokes to report inline model feedback survey results. + * + * Keep in sync with `CHAT_MODEL_FEEDBACK_SURVEY_TELEMETRY_COMMAND_ID` in + * `src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.ts`. + */ +const REPORT_SURVEY_COMMAND_ID = '_github.copilot.chat.reportModelFeedbackSurvey'; + +const TELEMETRY_EVENT_NAME = 'vscode.chatModelFeedbackSurvey'; + +const KNOWN_EVENT_KINDS: readonly string[] = ['shown', 'opened', 'step', 'submitted', 'dismissed']; + +/** Mirrors the workbench payload in `chatModelFeedbackSurveyTelemetry.ts`. */ +interface IChatModelFeedbackSurveyTelemetryEvent { + readonly kind: 'shown' | 'opened' | 'step' | 'submitted' | 'dismissed'; + readonly surveyId: string; + readonly surveyInstanceId: string; + readonly stepCount: number; + readonly trigger?: 'manual' | 'chance' | 'modelSwitchedAway'; + readonly stepId?: string; + readonly stepIndex?: number; + readonly answerId?: string; + readonly comment?: string; + readonly modelId?: string; + readonly resolvedModelId?: string; + readonly modeId?: string; + readonly harness?: string; + readonly sessionType?: string; + readonly requestId: string; +} + +/** + * Forwards inline model feedback survey results to GitHub restricted telemetry. + * + * The workbench cannot reach that endpoint, so it hands each result over as a command, which + * also activates this extension so early results are not lost. Every event is sent enhanced, + * including the ones carrying no answer, so the whole funnel shares one consent boundary. When + * the user has not opted in to restricted telemetry the send is a no op. + * + * Core already has a sender for the same table in + * `src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts`, using the same enhanced + * ingestion key. It is node layer and lives in the agent host process with no channel to the + * renderer, so it cannot serve workbench events today. Exposing it to the renderer would let + * this contribution and `GithubTelemetryForwardingContrib` both go away. + */ +export class ChatModelFeedbackSurveyForwardingContrib extends Disposable implements IExtensionContribution { + + constructor( + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + + this._register(commands.registerCommand(REPORT_SURVEY_COMMAND_ID, (event: unknown) => { + this._report(event); + })); + } + + private _report(event: unknown): void { + if (!isSurveyEvent(event)) { + return; + } + + const properties: Record = { + kind: event.kind, + surveyId: event.surveyId, + surveyInstanceId: event.surveyInstanceId, + }; + const measurements: Record = { + stepCount: event.stepCount, + }; + + addProperty(properties, 'trigger', event.trigger); + addProperty(properties, 'stepId', event.stepId); + addProperty(properties, 'answerId', event.answerId); + addProperty(properties, 'comment', event.comment); + addProperty(properties, 'modelId', event.modelId); + addProperty(properties, 'resolvedModelId', event.resolvedModelId); + addProperty(properties, 'modeId', event.modeId); + addProperty(properties, 'harness', event.harness); + addProperty(properties, 'sessionType', event.sessionType); + addProperty(properties, 'requestId', event.requestId); + + if (typeof event.stepIndex === 'number') { + measurements.stepIndex = event.stepIndex; + } + + const telemetryProperties: TelemetryEventProperties = properties; + const telemetryMeasurements: TelemetryEventMeasurements = measurements; + this._telemetryService.sendEnhancedGHTelemetryEvent(TELEMETRY_EVENT_NAME, telemetryProperties, telemetryMeasurements); + } +} + +function isSurveyEvent(event: unknown): event is IChatModelFeedbackSurveyTelemetryEvent { + if (typeof event !== 'object' || event === null) { + return false; + } + const candidate = event as IChatModelFeedbackSurveyTelemetryEvent; + return KNOWN_EVENT_KINDS.includes(candidate.kind) + && typeof candidate.surveyId === 'string' + && typeof candidate.surveyInstanceId === 'string' + && typeof candidate.stepCount === 'number' + && typeof candidate.requestId === 'string'; +} + +function addProperty(properties: Record, key: string, value: string | undefined): void { + if (typeof value === 'string' && value.length > 0) { + properties[key] = value; + } +} diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 22430f76741a9..63c37073b00aa 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -213,8 +213,9 @@ export class ToolBar extends Disposable { return this.element; } - focus(): void { - this.actionBar.focus(); + /** Focuses the item at `index`, or the first item when no index is given. */ + focus(index?: number): void { + this.actionBar.focus(index); } getItemsWidth(): number { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatModelFeedbackSurveyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatModelFeedbackSurveyActions.ts new file mode 100644 index 0000000000000..1263c832e4c38 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/chatModelFeedbackSurveyActions.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, markAsSingleton } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../../nls.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { isResponseVM } from '../../common/model/chatViewModel.js'; +import { IChatModelFeedbackSurveyService } from '../feedbackSurvey/chatModelFeedbackSurveyService.js'; +import '../feedbackSurvey/media/chatModelFeedbackSurvey.css'; +import { CHAT_CATEGORY } from './chatActions.js'; + +export const ChatModelFeedbackSurveyActionId = 'workbench.action.chat.openModelFeedbackSurvey'; + +const thumbsUpIconClasses = ThemeIcon.asClassNameArray(Codicon.thumbsup); +const thumbsDownIconClasses = ThemeIcon.asClassNameArray(Codicon.thumbsdown); + +/** + * The combined thumbs up and down control that stands in for the helpful and unhelpful actions + * while a survey applies. It is neutral and opens the survey rather than recording a vote. + */ +class ChatModelFeedbackSurveyActionViewItem extends MenuEntryActionViewItem { + + override render(container: HTMLElement): void { + super.render(container); + + if (!this.element || !this.label) { + return; + } + + // The label is the focusable anchor that carries the accessible name, so the styling and + // the icons hang off it rather than off the outer list item. + this.label.classList.add('chat-feedback-survey-pill'); + this.resetLabel(); + + const icons = dom.append(this.label, dom.$('.chat-feedback-survey-pill-icons')); + icons.setAttribute('aria-hidden', 'true'); + for (const iconClasses of [thumbsUpIconClasses, thumbsDownIconClasses]) { + const icon = dom.append(icons, dom.$('.chat-feedback-survey-pill-icon')); + icon.classList.add(...iconClasses); + } + } + + protected override updateClass(): void { + super.updateClass(); + this.resetLabel(); + } + + /** + * The control discloses a panel rather than holding a pressed state, so it reports + * `aria-expanded` instead of the `aria-pressed` the base item would apply. + */ + protected override updateChecked(): void { + super.updateChecked(); + this.label?.removeAttribute('aria-pressed'); + this.label?.setAttribute('aria-expanded', String(!!this.action.checked)); + } + + /** + * The base item paints one icon onto the label, so that has to be cleared before drawing two. + * The label keeps its `aria-label` and only the icons are hidden from screen readers. + */ + private resetLabel(): void { + if (!this.label) { + return; + } + this.label.classList.remove('icon', ...thumbsUpIconClasses, ...thumbsDownIconClasses); + this.label.style.backgroundImage = ''; + this.label.textContent = ''; + } +} + +export class ChatModelFeedbackSurveyActionRendering extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.modelFeedbackSurveyActionRendering'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const disposable = this._register(actionViewItemService.register(MenuId.ChatMessageFooter, ChatModelFeedbackSurveyActionId, (action, options) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(ChatModelFeedbackSurveyActionViewItem, action, options); + })); + + markAsSingleton(disposable); + } +} + +/** The part of a toolbar needed to find and focus one of its actions. */ +export interface IFeedbackSurveyToolBar { + getItemsLength(): number; + getItemAction(index: number): IAction | undefined; + focus(index?: number): void; +} + +/** + * Puts focus on the feedback control in `toolbar`, used when the survey panel it opened is torn + * down. Falls back to the toolbar itself when the control is not currently shown. + */ +export function focusChatModelFeedbackSurveyAction(toolbar: IFeedbackSurveyToolBar): void { + for (let i = 0; i < toolbar.getItemsLength(); i++) { + if (toolbar.getItemAction(i)?.id === ChatModelFeedbackSurveyActionId) { + toolbar.focus(i); + return; + } + } + toolbar.focus(); +} + +export function registerChatModelFeedbackSurveyActions(): void { + registerAction2(class OpenModelFeedbackSurveyAction extends Action2 { + constructor() { + super({ + id: ChatModelFeedbackSurveyActionId, + title: localize2('chat.feedbackSurvey.open.label', "Give Feedback"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.thumbsup, + toggled: ChatContextKeys.responseFeedbackSurveyOpen, + menu: [{ + id: MenuId.ChatMessageFooter, + group: 'navigation', + order: 2, + // The survey service checks these too, so a shown report is never sent for a + // control that cannot render. This drops the vote actions' + // `lockedToCodingAgent.negate()` because every agent host session is locked to + // its agent, which would make the `harnesses` selector unreachable. + when: ContextKeyExpr.and( + ChatContextKeys.responseHasFeedbackSurvey, + ChatContextKeys.isResponse, + ChatContextKeys.responseHasError.negate(), + ContextKeyExpr.has('config.telemetry.feedback.enabled'), + ), + }], + }); + } + + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const item = args[0]; + if (!isResponseVM(item)) { + return; + } + accessor.get(IChatModelFeedbackSurveyService).toggle(item); + } + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index 215aabfbeed2c..f1b9f230a3997 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -45,7 +45,7 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.responseHasFeedbackSurvey.negate()) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', @@ -90,7 +90,7 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 3, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.responseHasFeedbackSurvey.negate()) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', 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 50f8e61d9ebbd..75d1998716f01 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -97,6 +97,9 @@ import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCod import { ChatContextContributions } from './actions/chatContext.js'; import { registerChatContextActions } from './actions/chatContextActions.js'; import { ChatCopyActionRendering, registerChatCopyActions } from './actions/chatCopyActions.js'; +import { ChatModelFeedbackSurveyActionRendering, registerChatModelFeedbackSurveyActions } from './actions/chatModelFeedbackSurveyActions.js'; +import { ChatModelFeedbackSurveyService, IChatModelFeedbackSurveyService } from './feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { ChatModelFeedbackSurveyPromptContribution } from './feedbackSurvey/chatModelFeedbackSurveyPromptContribution.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; import { registerChatElicitationActions } from './actions/chatElicitationActions.js'; import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; @@ -3013,6 +3016,8 @@ registerWorkbenchContribution2(ChatPromptFilesExtensionPointHandler.ID, ChatProm registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually); registerWorkbenchContribution2(CodeBlockActionRendering.ID, CodeBlockActionRendering, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatCopyActionRendering.ID, ChatCopyActionRendering, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatModelFeedbackSurveyActionRendering.ID, ChatModelFeedbackSurveyActionRendering, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatModelFeedbackSurveyPromptContribution.ID, ChatModelFeedbackSurveyPromptContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitContextContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); @@ -3055,6 +3060,7 @@ registerWorkbenchContribution2(TranscriptContextAttachmentWidgetContribution.ID, registerChatActions(); registerChatAccessibilityActions(); registerChatCopyActions(); +registerChatModelFeedbackSurveyActions(); registerChatOpenAgentDebugPanelAction(); registerChatCodeBlockActions(); registerChatCodeCompareBlockActions(); @@ -3096,6 +3102,7 @@ registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delay registerSingleton(IChatPasteTargetService, ChatPasteTargetService, InstantiationType.Delayed); registerSingleton(IChatSideChatService, ChatSideChatService, InstantiationType.Delayed); registerSingleton(IChatRequestOriginService, ChatRequestOriginService, InstantiationType.Delayed); +registerSingleton(IChatModelFeedbackSurveyService, ChatModelFeedbackSurveyService, InstantiationType.Delayed); registerSingleton(IChatPetService, ChatPetService, InstantiationType.Delayed); registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed); registerSingleton(IChatAccessibilityService, ChatAccessibilityService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.ts b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.ts new file mode 100644 index 0000000000000..f0bbf1b0b0993 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { autorun, observableSignalFromEvent } from '../../../../../base/common/observable.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IChatWidget, IChatWidgetService } from '../chat.js'; +import { IChatModelFeedbackSurveyService } from './chatModelFeedbackSurveyService.js'; + +/** + * Watches the model picker of every chat widget and reports switches to the survey service. + * + * It sits outside the picker so model selection knows nothing about surveys, and outside the + * service so the service stays free of widget lifecycle and easy to test. + */ +export class ChatModelFeedbackSurveyPromptContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.modelFeedbackSurveyPrompt'; + + private readonly widgetListeners = this._register(new DisposableMap()); + + constructor( + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatModelFeedbackSurveyService private readonly surveyService: IChatModelFeedbackSurveyService, + ) { + super(); + + for (const widget of this.chatWidgetService.getAllWidgets()) { + this.trackWidget(widget); + } + this._register(this.chatWidgetService.onDidAddWidget(widget => this.trackWidget(widget))); + this._register(this.chatWidgetService.onDidRemoveWidget(widget => this.widgetListeners.deleteAndDispose(widget))); + } + + private trackWidget(widget: IChatWidget): void { + const listeners = new DisposableStore(); + + // The widget loads its session after it registers, and the model resolves around the same + // time. Re-running on that event keeps the pairing below anchored to the right session. + const viewModelChanged = observableSignalFromEvent('chatFeedbackSurveyViewModel', widget.onDidChangeViewModel); + let previous: { readonly modelId: string; readonly session: string } | undefined; + + listeners.add(autorun(reader => { + viewModelChanged.read(reader); + const modelId = widget.input.selectedLanguageModel.read(reader)?.identifier; + const sessionResource = widget.viewModel?.sessionResource; + const session = sessionResource?.toString(); + + const last = previous; + previous = modelId && session ? { modelId, session } : undefined; + + // Loading a different session restores that session's own model, so the next change is + // measured from there rather than from whatever the previous session was using. + if (last && last.session !== session) { + previous = undefined; + return; + } + + // Only a move between two known models counts. The first resolution and any gap while + // models load are not the user rejecting anything. + if (!last || !modelId || !sessionResource || last.modelId === modelId) { + return; + } + + this.surveyService.notifyModelSwitchedAway(sessionResource, last.modelId, modelId); + })); + + this.widgetListeners.set(widget, listeners); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts new file mode 100644 index 0000000000000..b7d800da4dd5d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.ts @@ -0,0 +1,673 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { escapeModelIdForTelemetry, ITelemetryService, TelemetryLevel } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { ChatModelFeedbackSurveyStepKind, expandModelMatchCandidates, IChatModelFeedbackSurveyConfig, matchesChatModelFeedbackSurvey, parseChatModelFeedbackSurveyConfig } from '../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; +import { CHAT_MODEL_FEEDBACK_SURVEY_TELEMETRY_COMMAND_ID, ChatModelFeedbackSurveyEventKind, IChatModelFeedbackSurveyTelemetryEvent } from '../../common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.js'; +import { ILanguageModelsService } from '../../common/languageModels.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; +import { IChatResponseViewModel } from '../../common/model/chatViewModel.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; + +/** Name of the experiment treatment carrying the survey payload. */ +export const CHAT_MODEL_FEEDBACK_SURVEY_TREATMENT = 'chatModelFeedbackSurvey'; + +/** Gates the in product feedback UI. The survey replaces thumbs up and down, so it obeys this too. */ +const FEEDBACK_ENABLED_CONFIG = 'telemetry.feedback.enabled'; + +const STORAGE_PREFIX = 'chat.modelFeedbackSurvey.'; + +export const enum ChatModelFeedbackSurveyStatus { + /** Available but not showing: only the feedback control is rendered. */ + Collapsed = 'collapsed', + Open = 'open', +} + +/** What caused the survey to open, recorded so the funnel can separate the two paths. */ +export type ChatModelFeedbackSurveyOpenTrigger = 'manual' | 'chance' | 'modelSwitchedAway'; + +export interface IChatModelFeedbackSurveyState { + readonly config: IChatModelFeedbackSurveyConfig; + readonly instanceId: string; + readonly status: ChatModelFeedbackSurveyStatus; + /** Index into `config.steps` of the step currently being shown. */ + readonly stepIndex: number; + /** Answers so far, keyed by step id. Choice steps store an option id, text steps the comment. */ + readonly answers: ReadonlyMap; + /** Uncommitted free text, preserved across the widget being recycled by virtualization. */ + readonly commentDraft: string; + /** Whether this survey was submitted. Reopening it acknowledges rather than asks again. */ + readonly isSubmitted: boolean; + /** What opened the survey, so the UI can take focus only when the user asked for it. */ + readonly openTrigger: ChatModelFeedbackSurveyOpenTrigger | undefined; +} + +/** Identifies the response whose survey changed, without retaining its view model. */ +export interface IChatModelFeedbackSurveyChangeEvent { + readonly sessionResource: URI; + readonly requestId: string; +} + +export const IChatModelFeedbackSurveyService = createDecorator('chatModelFeedbackSurveyService'); + +export interface IChatModelFeedbackSurveyService { + readonly _serviceBrand: undefined; + + /** Fires when a response's survey state changes, so the row can re-render. */ + readonly onDidChangeSurveyState: Event; + + /** + * Fires when the configured survey changes, including when it first resolves. Rows rendered + * before that point carry no control until they re-render, so they listen for this. + */ + readonly onDidChangeConfiguration: Event; + + /** + * The survey attached to a response, or `undefined` when the config does not apply to it. + * + * Presence depends only on the `match` rules and never on the prompting heuristics, so the + * control does not come and go between responses. Repeated calls return the same state, so a + * row scrolling back into view keeps its control and is not asked to prompt twice. + */ + getSurvey(response: IChatResponseViewModel): IChatModelFeedbackSurveyState | undefined; + + /** + * Opens the survey, or closes it when it is already showing. Manual opens are never rate + * limited, because the prompting rules only exist to pace surveys the user did not ask for. + */ + toggle(response: IChatResponseViewModel): void; + + /** + * Reports that the user moved the model picker from one model to another. Leaving a surveyed + * model for an unsurveyed one is the strongest signal that the model was wrong. + */ + notifyModelSwitchedAway(sessionResource: URI, fromModelId: string, toModelId: string): void; + answerChoice(response: IChatResponseViewModel, stepId: string, optionId: string): void; + submit(response: IChatResponseViewModel, comment?: string): void; + dismiss(response: IChatResponseViewModel): void; + /** Records in-progress free text without re-rendering, so it survives row recycling. */ + setCommentDraft(response: IChatResponseViewModel, comment: string): void; +} + +interface IMutableSurveyState { + readonly config: IChatModelFeedbackSurveyConfig; + readonly instanceId: string; + readonly sessionResource: URI; + readonly requestId: string; + status: ChatModelFeedbackSurveyStatus; + stepIndex: number; + readonly answers: Map; + commentDraft: string; + isSubmitted: boolean; + openTrigger: ChatModelFeedbackSurveyOpenTrigger | undefined; + /** Dimensions read from the response when the survey was created. */ + readonly dimensions: IChatModelFeedbackSurveyDimensions; + /** Guards against re-emitting `shown` when a virtualized row is re-rendered. */ + shownReported: boolean; + /** Text already reported, so submitting and dismissing cannot report it twice. */ + reportedComment?: string; +} + +type IChatModelFeedbackSurveyDimensions = Pick; + +/** Whether the user is part way through answering, as opposed to done or not started. */ +function isInProgress(state: IMutableSurveyState): boolean { + return state.status === ChatModelFeedbackSurveyStatus.Open && !state.isSubmitted; +} + +export class ChatModelFeedbackSurveyService extends Disposable implements IChatModelFeedbackSurveyService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeSurveyState = this._register(new Emitter()); + readonly onDidChangeSurveyState: Event = this._onDidChangeSurveyState.event; + + private readonly _onDidChangeConfiguration = this._register(new Emitter()); + readonly onDidChangeConfiguration: Event = this._onDidChangeConfiguration.event; + + private _config: IChatModelFeedbackSurveyConfig | undefined; + private _configResolved = false; + /** Increments on every treatment refresh so a slow in-flight resolution cannot overwrite a newer one. */ + private _configGeneration = 0; + + private readonly _states = new Map(); + /** How many times the survey has opened itself in each session, keyed by session resource. */ + private readonly _sessionPromptCounts = new Map(); + /** The most recent response carrying a survey in each session, for event-driven prompting. */ + private readonly _lastSurveyedResponse = new Map(); + + constructor( + @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IStorageService private readonly storageService: IStorageService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ICommandService private readonly commandService: ICommandService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IChatService private readonly chatService: IChatService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + void this.resolveConfig(); + this._register(this.assignmentService.onDidRefetchAssignments(() => void this.resolveConfig())); + this._register(this.chatService.onDidDisposeSession(e => this.forgetSessions(e.sessionResources))); + } + + private async resolveConfig(): Promise { + const generation = ++this._configGeneration; + let payload: string | undefined; + try { + payload = await this.assignmentService.getTreatment(CHAT_MODEL_FEEDBACK_SURVEY_TREATMENT); + } catch (err) { + this.logService.trace(`[chatModelFeedbackSurvey] failed to resolve treatment: ${err}`); + } + + if (generation !== this._configGeneration) { + return; // a newer resolution won + } + + const previousId = this._config?.id; + if (payload === undefined) { + this._config = undefined; + } else { + const result = parseChatModelFeedbackSurveyConfig(payload); + if (result.error) { + this.logService.warn(`[chatModelFeedbackSurvey] ignoring invalid survey config: ${result.error}`); + this._config = undefined; + } else { + this._config = result.config; + } + } + this._configResolved = true; + + // Only a different survey invalidates state, since reported step indices would no longer + // mean what they did when sent. A treatment that resolves to nothing happens while the + // experimentation client is rebuilt, and must not close an open survey or reset budgets. + if (this._config && this._config.id !== previousId) { + this._states.clear(); + this._sessionPromptCounts.clear(); + this._lastSurveyedResponse.clear(); + } + + if (this._config?.id !== previousId) { + this._onDidChangeConfiguration.fire(); + } + } + + getSurvey(response: IChatResponseViewModel): IChatModelFeedbackSurveyState | undefined { + const key = this.getKey(response); + const existing = this._states.get(key); + if (existing) { + // The config can be retired and feedback can be switched off after a survey was + // offered, so a cached one is only handed back while it still applies. A survey the + // user is part way through is left alone, since a treatment that briefly resolves to + // nothing must not take a form away mid answer. + const stillApplies = this._config?.id === existing.config.id && this.isFeedbackUiEnabled(); + if (!stillApplies && !isInProgress(existing)) { + this._states.delete(key); + return undefined; + } + // A survey the user is part way through stays put, so a form is never pulled away + // when a newer response arrives. Anything else follows the newest response. + if (!response.isLast && !isInProgress(existing)) { + this._states.delete(key); + return undefined; + } + this.reportShownOnce(existing); + return this.toReadonly(existing); + } + + // Only the newest response offers feedback, so history never fills with stale controls. + if (!response.isLast) { + return undefined; + } + + // Runs before the match check so a newer response the survey ignores still supersedes. + this.dropSupersededStates(response.sessionResource, key); + + const config = this.getMatchingConfig(response); + if (!config) { + return undefined; + } + + const state: IMutableSurveyState = { + config, + instanceId: generateUuid(), + sessionResource: response.sessionResource, + requestId: response.requestId, + status: ChatModelFeedbackSurveyStatus.Collapsed, + stepIndex: 0, + answers: new Map(), + commentDraft: '', + isSubmitted: false, + openTrigger: undefined, + dimensions: this.readDimensions(response), + shownReported: false, + }; + this._states.set(key, state); + this._lastSurveyedResponse.set(response.sessionResource.toString(), key); + this.reportShownOnce(state); + + // Rolled once per response so the outcome is stable however often the row re-renders. + // The caller is mid render and reads the state below, so this does not announce a change. + if (this.shouldPromptByChance(state)) { + this.beginPrompt(state, 'chance', false); + } + + return this.toReadonly(state); + } + + toggle(response: IChatResponseViewModel): void { + const state = this._states.get(this.getKey(response)); + if (!state) { + return; + } + if (state.status === ChatModelFeedbackSurveyStatus.Open) { + this.dismiss(response); + } else { + this.openState(state, 'manual'); + } + } + + notifyModelSwitchedAway(sessionResource: URI, fromModelId: string, toModelId: string): void { + const key = this._lastSurveyedResponse.get(sessionResource.toString()); + const state = key ? this._states.get(key) : undefined; + if (!state || state.status === ChatModelFeedbackSurveyStatus.Open || state.isSubmitted) { + return; + } + + // Leaving one surveyed model for another is not abandoning the thing being surveyed, and + // a switch between two unrelated models says nothing about it either. + if (!this.isSurveyedModel(state.config, fromModelId) || this.isSurveyedModel(state.config, toModelId)) { + return; + } + + const trigger = state.config.prompt.triggers.modelSwitchedAway; + if (!trigger.enabled || this.hasSurveyInProgress() || !this.hasPromptBudget(state, trigger.bypassCooldown)) { + return; + } + + this.beginPrompt(state, 'modelSwitchedAway'); + } + + /** Whether a model identifier is one the survey's `selectedModels` selectors name. */ + private isSurveyedModel(config: IChatModelFeedbackSurveyConfig, modelId: string): boolean { + const selectors = config.match.selectedModels; + if (!selectors.length) { + return false; // a survey that does not name a model cannot detect leaving one + } + const candidates = expandModelMatchCandidates(modelId, this.getModelAliases(modelId)); + return selectors.some(selector => candidates.has(selector)); + } + + /** The other identifiers a selector may name a model by. */ + private getModelAliases(modelId: string | undefined): string[] | undefined { + const metadata = modelId ? this.languageModelsService.lookupLanguageModel(modelId) : undefined; + return metadata ? [metadata.id, metadata.family, metadata.name, metadata.vendor] : undefined; + } + + answerChoice(response: IChatResponseViewModel, stepId: string, optionId: string): void { + const state = this._states.get(this.getKey(response)); + if (!state || state.status !== ChatModelFeedbackSurveyStatus.Open || state.isSubmitted) { + return; + } + + const stepIndex = state.config.steps.findIndex(step => step.id === stepId); + const step = state.config.steps[stepIndex]; + if (!step || step.kind !== ChatModelFeedbackSurveyStepKind.Choice) { + return; + } + // Only ids that came from the config may reach telemetry. + if (!step.options.some(option => option.id === optionId)) { + return; + } + + state.answers.set(stepId, optionId); + this.report(state, 'step', { stepId, stepIndex, answerId: optionId }); + + const isLastStep = stepIndex === state.config.steps.length - 1; + if (isLastStep) { + // A survey that ends on a choice has no Submit button, so the final selection is the + // submission. Without this the panel would re-render the same question forever. + this.finish(state, true, 'submitted'); + return; + } + + state.stepIndex = stepIndex + 1; + this._onDidChangeSurveyState.fire(this.toChangeEvent(state)); + } + + submit(response: IChatResponseViewModel, comment?: string): void { + const state = this._states.get(this.getKey(response)); + if (!state || state.status !== ChatModelFeedbackSurveyStatus.Open || state.isSubmitted) { + return; + } + + this.reportCommentOnce(state, comment); + this.finish(state, true, 'submitted'); + } + + dismiss(response: IChatResponseViewModel): void { + const state = this._states.get(this.getKey(response)); + if (!state || state.status !== ChatModelFeedbackSurveyStatus.Open) { + return; + } + + // Closing an acknowledgement is not a dismissal, and reporting one would double count + // against the submission already sent. + if (state.isSubmitted) { + state.status = ChatModelFeedbackSurveyStatus.Collapsed; + this._onDidChangeSurveyState.fire(this.toChangeEvent(state)); + return; + } + + this.reportCommentOnce(state, state.commentDraft); + this.finish(state, false, 'dismissed'); + } + + setCommentDraft(response: IChatResponseViewModel, comment: string): void { + const state = this._states.get(this.getKey(response)); + if (!state || state.status !== ChatModelFeedbackSurveyStatus.Open) { + return; + } + // No change event, because re-rendering on every keystroke would fight the input. The + // draft lives here so recycling the widget cannot discard it. + state.commentDraft = comment; + } + + // --- automatic prompting + + /** + * Decides whether a newly surveyed response should prompt on its own. The odds ramp with + * every response that passed without prompting, so heavier users reach the survey sooner. + */ + private shouldPromptByChance(state: IMutableSurveyState): boolean { + const { chance } = state.config.prompt; + if (chance.initial <= 0 && chance.increment <= 0) { + return false; + } + if (this.hasSurveyInProgress()) { + return false; + } + if (!this.hasPromptBudget(state, false)) { + return false; + } + + const misses = this.readPromptMisses(state.config); + const probability = Math.min(chance.initial + (chance.increment * misses), chance.max); + if (Math.random() < probability) { + return true; + } + + this.writePromptMisses(state.config, misses + 1); + return false; + } + + /** Releases everything held for sessions that have gone away. */ + private forgetSessions(sessionResources: readonly URI[]): void { + for (const sessionResource of sessionResources) { + const session = sessionResource.toString(); + this._sessionPromptCounts.delete(session); + this._lastSurveyedResponse.delete(session); + for (const [key, state] of [...this._states]) { + if (state.sessionResource.toString() === session) { + this._states.delete(key); + } + } + } + } + + /** Whether any survey is part way through, which an unrequested prompt must not displace. */ + private hasSurveyInProgress(): boolean { + for (const state of this._states.values()) { + if (isInProgress(state)) { + return true; + } + } + return false; + } + + private hasPromptBudget(state: IMutableSurveyState, bypassCooldown: boolean): boolean { + const { prompt } = state.config; + const sessionKey = state.sessionResource.toString(); + if ((this._sessionPromptCounts.get(sessionKey) ?? 0) >= prompt.maxPerSession) { + return false; + } + if (bypassCooldown || prompt.cooldownDays <= 0) { + return true; + } + + const lastPromptAt = this.readLastPromptAt(state.config); + if (lastPromptAt === undefined) { + return true; + } + return Date.now() - lastPromptAt >= prompt.cooldownDays * 24 * 60 * 60 * 1000; + } + + /** Opens the survey unprompted and charges it against the pacing budgets. */ + private beginPrompt(state: IMutableSurveyState, trigger: ChatModelFeedbackSurveyOpenTrigger, announce = true): void { + const sessionKey = state.sessionResource.toString(); + this._sessionPromptCounts.set(sessionKey, (this._sessionPromptCounts.get(sessionKey) ?? 0) + 1); + this.writeLastPromptAt(state.config, Date.now()); + this.writePromptMisses(state.config, 0); + this.openState(state, trigger, announce); + } + + private openState(state: IMutableSurveyState, trigger: ChatModelFeedbackSurveyOpenTrigger, announce = true): void { + this.closeOtherOpenSurveys(state); + // A submitted survey reopens read only, so it needs no new instance. + if (!state.isSubmitted && state.stepIndex >= state.config.steps.length) { + state.stepIndex = 0; + } + state.status = ChatModelFeedbackSurveyStatus.Open; + state.openTrigger = trigger; + if (!state.isSubmitted) { + this.report(state, 'opened', { trigger }); + } + if (announce) { + this._onDidChangeSurveyState.fire(this.toChangeEvent(state)); + } + } + + private finish(state: IMutableSurveyState, submitted: boolean, kind: ChatModelFeedbackSurveyEventKind): void { + const stepIndex = state.stepIndex; + state.isSubmitted = submitted; + // A submitted survey stays open to acknowledge. An abandoned one closes and can be + // reopened, since the user never answered it. + state.status = submitted ? ChatModelFeedbackSurveyStatus.Open : ChatModelFeedbackSurveyStatus.Collapsed; + this.report(state, kind, { stepIndex }); + this._onDidChangeSurveyState.fire(this.toChangeEvent(state)); + } + + private toChangeEvent(state: IMutableSurveyState): IChatModelFeedbackSurveyChangeEvent { + return { sessionResource: state.sessionResource, requestId: state.requestId }; + } + + // --- eligibility + + private getMatchingConfig(response: IChatResponseViewModel): IChatModelFeedbackSurveyConfig | undefined { + if (!this._configResolved || !this._config) { + return undefined; + } + if (!this.isFeedbackUiEnabled()) { + return undefined; + } + if (!response.isComplete || response.isCanceled || response.errorDetails) { + return undefined; + } + + const request = response.model.request; + const selectedModelId = request?.modelId; + + return matchesChatModelFeedbackSurvey(this._config, { + selectedModelId, + selectedModelAliases: this.getModelAliases(selectedModelId), + resolvedModelId: this.getResolvedModelId(response), + modeId: request?.modeInfo?.telemetryModeId, + harness: this.getHarness(response.sessionResource), + sessionType: getChatSessionType(response.sessionResource), + }) ? this._config : undefined; + } + + /** Answers that could never be sent must not be collected in the first place. */ + private isFeedbackUiEnabled(): boolean { + return this.configurationService.getValue(FEEDBACK_ENABLED_CONFIG) !== false + && this.telemetryService.telemetryLevel !== TelemetryLevel.NONE; + } + + private getResolvedModelId(response: IChatResponseViewModel): string | undefined { + const resolvedModel = response.result?.metadata?.resolvedModel; + return typeof resolvedModel === 'string' ? resolvedModel : undefined; + } + + /** Normalizes local and remote session types to one provider id, which is what a config targets. */ + private getHarness(sessionResource: URI): string | undefined { + return this.chatSessionsService.getChatSessionContribution(getChatSessionType(sessionResource))?.agentHostProviderId; + } + + // --- prompt pacing storage + + private readPromptMisses(config: IChatModelFeedbackSurveyConfig): number { + const value = this.storageService.getNumber(`${STORAGE_PREFIX}${config.id}.promptMisses`, StorageScope.PROFILE, 0); + return Number.isFinite(value) && value > 0 ? value : 0; + } + + private writePromptMisses(config: IChatModelFeedbackSurveyConfig, misses: number): void { + this.storageService.store(`${STORAGE_PREFIX}${config.id}.promptMisses`, misses, StorageScope.PROFILE, StorageTarget.MACHINE); + } + + private readLastPromptAt(config: IChatModelFeedbackSurveyConfig): number | undefined { + const value = this.storageService.getNumber(`${STORAGE_PREFIX}${config.id}.lastPromptAt`, StorageScope.PROFILE); + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; + } + + private writeLastPromptAt(config: IChatModelFeedbackSurveyConfig, timestamp: number): void { + this.storageService.store(`${STORAGE_PREFIX}${config.id}.lastPromptAt`, timestamp, StorageScope.PROFILE, StorageTarget.MACHINE); + } + + // --- state bookkeeping + + private getKey(response: IChatResponseViewModel): string { + return `${response.sessionResource.toString()}\u0000${response.requestId}`; + } + + private toReadonly(state: IMutableSurveyState): IChatModelFeedbackSurveyState { + return { + config: state.config, + instanceId: state.instanceId, + status: state.status, + stepIndex: state.stepIndex, + answers: state.answers, + commentDraft: state.commentDraft, + isSubmitted: state.isSubmitted, + openTrigger: state.openTrigger, + }; + } + + /** + * Closes whichever survey was already showing, since only one is open at a time. This is the + * UI moving on rather than the user rejecting anything, so nothing is reported. + */ + private closeOtherOpenSurveys(keep: IMutableSurveyState): void { + for (const other of [...this._states.values()]) { + if (other === keep || other.status !== ChatModelFeedbackSurveyStatus.Open) { + continue; + } + other.status = ChatModelFeedbackSurveyStatus.Collapsed; + this._onDidChangeSurveyState.fire(this.toChangeEvent(other)); + } + } + + /** + * Within one session, keeps the newest response plus anything the user is part way through. + * Other sessions are separate transcripts and are left alone. + */ + private dropSupersededStates(sessionResource: URI, currentKey: string): void { + const session = sessionResource.toString(); + for (const [key, state] of [...this._states]) { + if (key !== currentKey && state.sessionResource.toString() === session && !isInProgress(state)) { + this._states.delete(key); + } + } + } + + // --- telemetry + + private reportShownOnce(state: IMutableSurveyState): void { + if (state.shownReported) { + return; + } + state.shownReported = true; + this.report(state, 'shown', {}); + } + + private reportCommentOnce(state: IMutableSurveyState, comment: string | undefined): void { + // Validation guarantees at most one text step, and that it is last. + const textStep = state.config.steps.at(-1); + if (textStep?.kind !== ChatModelFeedbackSurveyStepKind.Text) { + return; + } + const trimmed = comment?.trim(); + if (!trimmed || trimmed === state.reportedComment) { + return; + } + + const clamped = trimmed.slice(0, textStep.maxLength); + state.reportedComment = trimmed; + state.answers.set(textStep.id, clamped); + this.report(state, 'step', { + stepId: textStep.id, + stepIndex: state.config.steps.length - 1, + comment: clamped, + }); + } + + private readDimensions(response: IChatResponseViewModel): IChatModelFeedbackSurveyDimensions { + const request = response.model.request; + return { + modelId: escapeModelIdForTelemetry(request?.modelId), + resolvedModelId: escapeModelIdForTelemetry(this.getResolvedModelId(response)), + modeId: request?.modeInfo?.telemetryModeId, + harness: this.getHarness(response.sessionResource), + sessionType: getChatSessionType(response.sessionResource), + }; + } + + private report( + state: IMutableSurveyState, + kind: ChatModelFeedbackSurveyEventKind, + details: Pick, + ): void { + if (!this.isFeedbackUiEnabled()) { + return; // never let survey content cross the process boundary when feedback is off + } + + const event: IChatModelFeedbackSurveyTelemetryEvent = { + kind, + surveyId: state.config.id, + surveyInstanceId: state.instanceId, + stepCount: state.config.steps.length, + ...details, + ...state.dimensions, + requestId: state.requestId, + }; + + // Best effort: the survey must never interfere with the chat session. + this.commandService.executeCommand(CHAT_MODEL_FEEDBACK_SURVEY_TELEMETRY_COMMAND_ID, event) + .catch(err => this.logService.trace(`[chatModelFeedbackSurvey] failed to report '${kind}': ${err}`)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts new file mode 100644 index 0000000000000..4dc275f7f3f41 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatModelFeedbackSurveyStepKind, IChatModelFeedbackSurveyTextStep } from '../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; +import { IChatResponseViewModel } from '../../common/model/chatViewModel.js'; +import { ChatModelFeedbackSurveyStatus, IChatModelFeedbackSurveyService, IChatModelFeedbackSurveyState } from './chatModelFeedbackSurveyService.js'; +import './media/chatModelFeedbackSurvey.css'; + +/** Keys the chat list acts on, which the survey has to claim while it is focused. */ +const KEYS_HANDLED_BY_LIST = new Set([ + KeyCode.UpArrow, + KeyCode.DownArrow, + KeyCode.LeftArrow, + KeyCode.RightArrow, + KeyCode.PageUp, + KeyCode.PageDown, + KeyCode.Enter, + KeyCode.Space, + KeyCode.Escape, + KeyCode.Home, + KeyCode.End, +]); + +const TEXT_INPUT_MAX_HEIGHT = 96; + +/** Stands in for a step key once the survey is answered and only acknowledges. */ +const ACKNOWLEDGEMENT_STEP_KEY = 'acknowledged'; + +/** + * The inline survey shown beneath a chat response footer. + * + * It renders below the footer toolbar so opening it never moves the footer icons. All durable + * state lives in the survey service, since chat rows are virtualized and recycle this widget. + */ +export class ChatModelFeedbackSurveyWidget extends Disposable { + + private readonly renderDisposables = this._register(new DisposableStore()); + private response: IChatResponseViewModel | undefined; + private readonly hasSurveyContextKey: IContextKey; + private readonly surveyOpenContextKey: IContextKey; + /** Step focus was last moved to, so an unrelated re-render does not move it again. */ + private lastFocusedStep: string | undefined; + private isPanelOpen = false; + /** Guards against `render` being re-entered when reading state changes that state. */ + private isRendering = false; + + constructor( + private readonly container: HTMLElement, + /** Returns focus to whatever opened the survey once its controls are torn down. */ + private readonly restoreFocus: () => void, + @IChatModelFeedbackSurveyService private readonly surveyService: IChatModelFeedbackSurveyService, + @IHoverService private readonly hoverService: IHoverService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + this.hasSurveyContextKey = ChatContextKeys.responseHasFeedbackSurvey.bindTo(contextKeyService); + this.surveyOpenContextKey = ChatContextKeys.responseFeedbackSurveyOpen.bindTo(contextKeyService); + + this._register(this.surveyService.onDidChangeSurveyState(e => { + if (this.response && e.requestId === this.response.requestId && e.sessionResource.toString() === this.response.sessionResource.toString()) { + this.render(this.response); + } + })); + + this._register(this.surveyService.onDidChangeConfiguration(() => this.render(this.response))); + + // Escape is handled in capture, because Button stops propagation on its own Escape. + this._register(dom.addDisposableListener(this.container, dom.EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Escape) { + event.stopPropagation(); + event.preventDefault(); + this.dismiss(); + } + }, true)); + + // Claimed on the way back up, after the survey has had its turn, so the chat list does + // not also move its selection. Stopping these in capture would starve the option list. + this._register(dom.addDisposableListener(this.container, dom.EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (KEYS_HANDLED_BY_LIST.has(event.keyCode) && !dom.isEditableElement(e.target as HTMLElement)) { + event.stopPropagation(); + } + })); + } + + /** Renders the survey for `response`, or clears the panel when there is nothing to show. */ + render(response: IChatResponseViewModel | undefined): void { + // Reading the survey can open it, which reports a state change back to this widget. The + // call already in flight reads the latest state, so a nested render would only duplicate + // what it is about to draw. + if (this.isRendering) { + return; + } + + this.isRendering = true; + try { + this.doRender(response); + } finally { + this.isRendering = false; + } + } + + private doRender(response: IChatResponseViewModel | undefined): void { + this.response = response; + this.renderDisposables.clear(); + dom.clearNode(this.container); + + const state = response && this.surveyService.getSurvey(response); + this.hasSurveyContextKey.set(!!state); + + const isOpen = !!state && state.status === ChatModelFeedbackSurveyStatus.Open; + const wasOpen = this.isPanelOpen; + this.isPanelOpen = isOpen; + this.surveyOpenContextKey.set(isOpen); + this.container.classList.toggle('hidden', !isOpen); + if (!response || !state || !isOpen) { + this.lastFocusedStep = undefined; + // The focused control has just been removed, so hand focus back to the control that + // opened the survey rather than letting it fall to the document body. + if (wasOpen) { + this.restoreFocus(); + } + return; + } + + this.renderPanel(response, state); + } + + private renderPanel(response: IChatResponseViewModel, state: IChatModelFeedbackSurveyState): void { + const step = state.config.steps[state.stepIndex]; + if (!step) { + return; + } + + const panel = dom.append(this.container, dom.$('.chat-feedback-survey-container')); + const header = dom.append(panel, dom.$('.chat-feedback-survey-header')); + const title = dom.append(header, dom.$('.chat-feedback-survey-title')); + title.textContent = state.isSubmitted + ? localize('chat.feedbackSurvey.acknowledgement', "Thanks, your feedback has been recorded.") + : step.title; + + const closeButton = this.renderCloseButton(header); + + // An answered survey has nothing left to ask, so it only acknowledges. + if (state.isSubmitted) { + if (this.lastFocusedStep !== ACKNOWLEDGEMENT_STEP_KEY) { + this.lastFocusedStep = ACKNOWLEDGEMENT_STEP_KEY; + status(localize('chat.feedbackSurvey.submitted', "Feedback submitted. Thank you.")); + // Submitting removed the control that had focus, so move it to the one left. + closeButton.focus(); + } + return; + } + + const body = dom.append(panel, dom.$('.chat-feedback-survey-body')); + const firstControl = step.kind === ChatModelFeedbackSurveyStepKind.Choice + ? this.renderChoiceStep(response, body, state.instanceId, step.id, step.options, step.title) + : this.renderTextStep(response, state, body, step); + + if (state.config.steps.length > 1) { + const progress = dom.append(panel, dom.$('.chat-feedback-survey-progress')); + progress.textContent = localize('chat.feedbackSurvey.progress', "Step {0} of {1}", state.stepIndex + 1, state.config.steps.length); + } + + const stepKey = `${state.instanceId}:${state.stepIndex}`; + if (this.lastFocusedStep !== stepKey) { + this.lastFocusedStep = stepKey; + status(localize('chat.feedbackSurvey.stepAnnouncement', "{0}. Step {1} of {2}.", step.title, state.stepIndex + 1, state.config.steps.length)); + // A survey the user asked for takes focus, one that appeared on its own does not. + if (state.openTrigger === 'manual' || state.stepIndex > 0) { + firstControl?.focus(); + } + } + } + + private renderCloseButton(header: HTMLElement): Button { + const label = localize('chat.feedbackSurvey.dismiss', "Dismiss Survey"); + const close = this.renderDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + close.label = `$(${Codicon.closeSmall.id})`; + close.element.classList.add('chat-feedback-survey-close'); + close.element.setAttribute('aria-label', label); + this.renderDisposables.add(this.hoverService.setupDelayedHover(close.element, { content: label })); + this.renderDisposables.add(close.onDidClick(() => this.dismiss())); + return close; + } + + /** Renders the options as a single select list, matching the ask question tool. */ + private renderChoiceStep(response: IChatResponseViewModel, body: HTMLElement, instanceId: string, stepId: string, options: readonly { id: string; label: string }[], title: string): HTMLElement { + const list = dom.append(body, dom.$('.chat-feedback-survey-list')); + list.setAttribute('role', 'listbox'); + list.setAttribute('aria-label', title); + list.tabIndex = 0; + + const items: HTMLElement[] = []; + let activeIndex = 0; + + const setActive = (index: number) => { + activeIndex = index; + items.forEach((item, i) => { + const isActive = i === index; + item.classList.toggle('active', isActive); + item.setAttribute('aria-selected', String(isActive)); + }); + list.setAttribute('aria-activedescendant', items[index].id); + }; + + options.forEach((option, index) => { + const item = dom.append(list, dom.$('.chat-feedback-survey-list-item')); + item.id = `chat-feedback-survey-option-${instanceId}-${stepId}-${index}`; + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', 'false'); + + const label = dom.append(item, dom.$('.chat-feedback-survey-list-label')); + label.textContent = option.label; + + this.renderDisposables.add(dom.addDisposableListener(item, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + this.surveyService.answerChoice(response, stepId, option.id); + })); + items.push(item); + }); + + setActive(0); + + this.renderDisposables.add(dom.addDisposableListener(list, dom.EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.DownArrow) { + event.preventDefault(); + setActive(activeIndex === items.length - 1 ? 0 : activeIndex + 1); + } else if (event.keyCode === KeyCode.UpArrow) { + event.preventDefault(); + setActive(activeIndex === 0 ? items.length - 1 : activeIndex - 1); + } else if (event.keyCode === KeyCode.Home) { + event.preventDefault(); + setActive(0); + } else if (event.keyCode === KeyCode.End) { + event.preventDefault(); + setActive(items.length - 1); + } else if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) { + event.preventDefault(); + this.surveyService.answerChoice(response, stepId, options[activeIndex].id); + } + })); + + return list; + } + + private renderTextStep(response: IChatResponseViewModel, state: IChatModelFeedbackSurveyState, body: HTMLElement, step: IChatModelFeedbackSurveyTextStep): HTMLElement { + const inputBox = this.renderDisposables.add(new InputBox(body, undefined, { + placeholder: step.placeholder, + ariaLabel: step.title, + inputBoxStyles: defaultInputBoxStyles, + flexibleHeight: true, + flexibleMaxHeight: TEXT_INPUT_MAX_HEIGHT, + })); + inputBox.value = state.commentDraft || state.answers.get(step.id) || ''; + inputBox.inputElement.maxLength = step.maxLength; + this.renderDisposables.add(inputBox.onDidChange(value => this.surveyService.setCommentDraft(response, value))); + + const actions = dom.append(body, dom.$('.chat-feedback-survey-actions')); + const submit = this.renderDisposables.add(new Button(actions, { ...defaultButtonStyles })); + submit.label = localize('chat.feedbackSurvey.submit', "Submit"); + this.renderDisposables.add(submit.onDidClick(() => this.surveyService.submit(response, inputBox.value))); + + return inputBox.inputElement; + } + + private dismiss(): void { + if (!this.response || !this.isPanelOpen) { + return; + } + this.surveyService.dismiss(this.response); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css new file mode 100644 index 0000000000000..11ab45dd98437 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Matches the ask question tool so the two inline surfaces read as one family. */ + +.chat-feedback-survey-widget.hidden { + display: none; +} + +.chat-feedback-survey-container { + display: flex; + flex-direction: column; + margin: 8px 0; + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); + border-radius: var(--vscode-cornerRadius-large); + background-color: var(--vscode-panel-background); + overflow: hidden; +} + +.chat-feedback-survey-container:focus-within { + border-color: var(--vscode-focusBorder); +} + +/* In the agents window and the editor the surface is the editor background. */ +.agent-sessions-workbench .chat-feedback-survey-container, +.editor-instance .chat-feedback-survey-container { + background-color: var(--vscode-editor-background); +} + +.chat-feedback-survey-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size160); + border-bottom: 1px solid var(--vscode-chat-requestBorder); +} + +.chat-feedback-survey-title { + flex: 1; + min-width: 0; + margin: 0; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 1.4; + overflow-wrap: anywhere; +} + +/* Chrome free, matching the close button on the ask question tool. */ +.chat-feedback-survey-container .monaco-button.chat-feedback-survey-close { + flex-shrink: 0; + width: 22px; + min-width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-icon-foreground) !important; +} + +.chat-feedback-survey-container .monaco-button.chat-feedback-survey-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; +} + +.chat-feedback-survey-body { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size80); +} + +.chat-feedback-survey-list { + display: flex; + flex-direction: column; +} + +/* + * The card border already shows focus, so the list must not draw a second ring. This needs to + * outrank the workbench rule for `[tabindex="0"]:focus` in style.css, hence the extra scoping. + */ +.chat-feedback-survey-widget .chat-feedback-survey-container .chat-feedback-survey-list:focus, +.chat-feedback-survey-widget .chat-feedback-survey-container .chat-feedback-survey-list-item:focus { + outline: none; +} + +.chat-feedback-survey-list-item { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-medium); + cursor: pointer; + user-select: none; +} + +.chat-feedback-survey-list-label { + flex: 1; + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 1.4; + overflow-wrap: break-word; +} + +.chat-feedback-survey-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.chat-feedback-survey-list-item.active { + background-color: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); +} + +.chat-feedback-survey-list:focus .chat-feedback-survey-list-item.active { + background-color: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); +} + +.chat-feedback-survey-actions { + display: flex; + justify-content: flex-end; +} + +.chat-feedback-survey-progress { + padding: 0 var(--vscode-spacing-size80) var(--vscode-spacing-size80); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +/* + * The combined thumbs control in the response footer. Both glyphs sit in one control so it + * reads as a single way to give feedback rather than two competing verdicts. The class is on + * the action-label anchor, which is the focusable element the toolbar renders. + */ +.action-label.chat-feedback-survey-pill { + width: auto; +} + +.action-label.chat-feedback-survey-pill .chat-feedback-survey-pill-icons { + display: flex; + align-items: center; +} + +.action-label.chat-feedback-survey-pill .chat-feedback-survey-pill-icon::before { + font-size: var(--vscode-codiconFontSize-compact); +} + +/* + * The glyphs overlap and sit on a slight diagonal so the pair reads as one icon on a single + * small button rather than as two separate votes. + */ +.action-label.chat-feedback-survey-pill .chat-feedback-survey-pill-icon:first-child { + transform: translateY(-2px); +} + +.action-label.chat-feedback-survey-pill .chat-feedback-survey-pill-icon:last-child { + margin-left: -5px; + transform: translateY(2px); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index fab5aca8fb37b..778643d1183e0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -67,7 +67,9 @@ import { formatChatRequestTimestamp, formatChatResponseDetails, formatChatRespon import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js'; import { ForkConversationActionId } from '../actions/chatForkActions.js'; import { MarkHelpfulActionId } from '../actions/chatTitleActions.js'; +import { focusChatModelFeedbackSurveyAction } from '../actions/chatModelFeedbackSurveyActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from '../chat.js'; +import { ChatModelFeedbackSurveyWidget } from '../feedbackSurvey/chatModelFeedbackSurveyWidget.js'; import { AgentHostSnapshotController } from '../agentSessions/agentHost/agentHostSnapshotController.js'; import { RestoreCheckpointActionId, StartOverActionId } from '../chatEditing/chatEditingActions.js'; import { ChatForkActionViewItem } from './chatForkActionViewItem.js'; @@ -192,6 +194,8 @@ export interface IChatListItemTemplate { readonly checkpointRestoreToolbar: MenuWorkbenchToolBar; readonly checkpointContainer: HTMLElement; readonly checkpointRestoreContainer: HTMLElement; + /** Inline model feedback survey shown beneath the footer, when an experiment offers one. */ + readonly feedbackSurveyWidget: ChatModelFeedbackSurveyWidget; } function escapeMarkdownLinkLabel(label: string): string { @@ -1040,6 +1044,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer focusChatModelFeedbackSurveyAction(footerToolbar))); + const checkpointRestoreContainer = dom.append(rowContainer, $('.checkpoint-restore-container')); dom.append(checkpointRestoreContainer, $('.checkpoint-line-left')); const label = dom.append(checkpointRestoreContainer, $('span.checkpoint-label-text')); @@ -1090,7 +1098,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { @@ -1250,8 +1258,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chatResponseSupportsIssueReporting', false, { type: 'boolean', description: localize('chatResponseSupportsIssueReporting', "True when the current chat response supports issue reporting.") }); export const responseIsFiltered = new RawContextKey('chatSessionResponseFiltered', false, { type: 'boolean', description: localize('chatResponseFiltered', "True when the chat response was filtered out by the server.") }); export const responseHasError = new RawContextKey('chatSessionResponseError', false, { type: 'boolean', description: localize('chatResponseErrored', "True when the chat response resulted in an error.") }); + export const responseHasFeedbackSurvey = new RawContextKey('chatSessionResponseHasFeedbackSurvey', false, { type: 'boolean', description: localize('chatResponseHasFeedbackSurvey', "True when an inline model feedback survey is offered for the chat response, which replaces the helpful and unhelpful actions.") }); + export const responseFeedbackSurveyOpen = new RawContextKey('chatSessionResponseFeedbackSurveyOpen', false, { type: 'boolean', description: localize('chatResponseFeedbackSurveyOpen', "True when the inline model feedback survey is showing for the chat response.") }); export const requestInProgress = new RawContextKey('chatSessionRequestInProgress', false, { type: 'boolean', description: localize('interactiveSessionRequestInProgress', "True when the current request is still in progress.") }); export const hasActiveRequest = new RawContextKey('chatSessionHasActiveRequest', false, { type: 'boolean', description: localize('chatSessionHasActiveRequest', "True when the current chat response has not completed, regardless of intermediate states like tool calls or elicitations.") }); export const currentlyEditing = new RawContextKey('chatSessionCurrentlyEditing', false, { type: 'boolean', description: localize('interactiveSessionCurrentlyEditing', "True when the current request is being edited.") }); diff --git a/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts b/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts new file mode 100644 index 0000000000000..acf05a5f6513e --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyConfig.ts @@ -0,0 +1,568 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Contract for the inline model feedback survey. + * + * A survey is fully described by a versioned JSON payload delivered as an experiment treatment, + * so one can be authored or retired without shipping code. The shapes stay close to the editor + * pane survey in `contrib/surveys/browser/surveyQuestions.ts` so the two can converge later, + * but cannot share code today because that renderer needs telemetry keys known at compile time. + */ + +/** Payload versions this build understands. Bump when making a breaking shape change. */ +export const CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION = 1; + +/** Sentinel matching sessions that are not backed by an agent host. */ +export const CHAT_MODEL_FEEDBACK_SURVEY_NO_HARNESS = 'none'; + +const MAX_STEPS = 8; +const MIN_OPTIONS = 2; +const MAX_OPTIONS = 8; +const MAX_ID_LENGTH = 64; +const MAX_TITLE_LENGTH = 200; +const MAX_LABEL_LENGTH = 120; +const MAX_PLACEHOLDER_LENGTH = 100; +const MAX_COMMENT_LENGTH = 1000; +const MAX_SELECTORS = 32; + +const MATCH_FIELDS = ['selectedModels', 'resolvedModels', 'modes', 'harnesses', 'sessionTypes'] as const; + +/** Ids appear in telemetry, so they are restricted to a shape that needs no sanitization. */ +const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +export const enum ChatModelFeedbackSurveyStepKind { + Choice = 'choice', + Text = 'text', +} + +export interface IChatModelFeedbackSurveyOption { + readonly id: string; + readonly label: string; +} + +interface IChatModelFeedbackSurveyStepBase { + readonly id: string; + readonly title: string; +} + +export interface IChatModelFeedbackSurveyChoiceStep extends IChatModelFeedbackSurveyStepBase { + readonly kind: ChatModelFeedbackSurveyStepKind.Choice; + readonly options: readonly IChatModelFeedbackSurveyOption[]; +} + +export interface IChatModelFeedbackSurveyTextStep extends IChatModelFeedbackSurveyStepBase { + readonly kind: ChatModelFeedbackSurveyStepKind.Text; + readonly placeholder?: string; + readonly maxLength: number; +} + +export type ChatModelFeedbackSurveyStep = IChatModelFeedbackSurveyChoiceStep | IChatModelFeedbackSurveyTextStep; + +/** + * Which responses a survey attaches to. An omitted or empty selector list means any. + * + * Selected and resolved models are matched separately on purpose. A survey about Auto routing + * targets the selected model `auto`, and must not fire just because another request happened to + * be routed to the same model. + */ +export interface IChatModelFeedbackSurveyMatch { + readonly selectedModels: readonly string[]; + readonly resolvedModels: readonly string[]; + readonly modes: readonly string[]; + /** Agent host provider ids (e.g. `copilotcli`), or {@link CHAT_MODEL_FEEDBACK_SURVEY_NO_HARNESS}. */ + readonly harnesses: readonly string[]; + readonly sessionTypes: readonly string[]; +} + +/** + * Rules governing when the survey opens *by itself*. + * + * None of this applies to manual activation: clicking the feedback control is an explicit + * request for the survey and always opens it. These rules exist only to keep unprompted + * surfacing rare enough not to be a nuisance. + */ +export interface IChatModelFeedbackSurveyPrompt { + /** Minimum days between two automatic prompts. `0` disables the cooldown. */ + readonly cooldownDays: number; + /** How many times the survey may open itself within one chat session. */ + readonly maxPerSession: number; + readonly chance: IChatModelFeedbackSurveyChance; + readonly triggers: IChatModelFeedbackSurveyTriggers; +} + +/** + * A probability that ramps with usage. Every eligible response that does not prompt raises the + * odds up to {@link IChatModelFeedbackSurveyChance.max}, so heavier users are asked sooner. The + * odds reset once a prompt is shown. + */ +export interface IChatModelFeedbackSurveyChance { + /** Probability applied to the first eligible response. `0` disables random prompting. */ + readonly initial: number; + /** Added to the probability for each eligible response that did not prompt. */ + readonly increment: number; + /** Ceiling the ramped probability cannot exceed. */ + readonly max: number; +} + +/** Moments that prompt directly, without a probability roll. */ +export interface IChatModelFeedbackSurveyTriggers { + /** Fires when the user switches the picker off a model this survey matches. */ + readonly modelSwitchedAway: IChatModelFeedbackSurveyTrigger; +} + +export interface IChatModelFeedbackSurveyTrigger { + readonly enabled: boolean; + /** Whether the trigger prompts even inside the cooldown window. */ + readonly bypassCooldown: boolean; +} + +export interface IChatModelFeedbackSurveyConfig { + readonly version: number; + readonly id: string; + readonly match: IChatModelFeedbackSurveyMatch; + readonly prompt: IChatModelFeedbackSurveyPrompt; + readonly steps: readonly ChatModelFeedbackSurveyStep[]; +} + +export type ChatModelFeedbackSurveyParseResult = + | { readonly config: IChatModelFeedbackSurveyConfig; readonly error?: undefined } + | { readonly config?: undefined; readonly error: string }; + +/** Describes the response a survey is matched against. The caller resolves any model aliases. */ +export interface IChatModelFeedbackSurveyMatchContext { + /** The model identifier the user selected, as recorded on the request. */ + readonly selectedModelId?: string; + /** Other identifiers for the selected model, such as its id, family, name and vendor. */ + readonly selectedModelAliases?: readonly string[]; + /** The model a routing layer (e.g. Auto) actually resolved to, when different. */ + readonly resolvedModelId?: string; + readonly modeId?: string; + /** Agent host provider id, or `undefined` for sessions with no agent host. */ + readonly harness?: string; + readonly sessionType?: string; +} + +/** + * Parses and validates a survey payload. Never throws, and rejects a bad config whole rather + * than in part, since dropping one malformed step would quietly change what the experiment + * measures. + */ +export function parseChatModelFeedbackSurveyConfig(raw: string | undefined): ChatModelFeedbackSurveyParseResult { + if (typeof raw !== 'string' || !raw.trim()) { + return { error: 'empty payload' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { error: `payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }; + } + + if (!isObject(parsed)) { + return { error: 'payload is not an object' }; + } + + if (parsed.version !== CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION) { + return { error: `unsupported version ${JSON.stringify(parsed.version)}, expected ${CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION}` }; + } + + const id = readId(parsed.id); + if (!id) { + return { error: 'missing or malformed survey id' }; + } + + const match = readMatch(parsed.match); + if (typeof match === 'string') { + return { error: match }; + } + + const prompt = readPrompt(parsed.prompt); + if (typeof prompt === 'string') { + return { error: prompt }; + } + + const steps = readSteps(parsed.steps); + if (typeof steps === 'string') { + return { error: steps }; + } + + return { config: { version: CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION, id, match, prompt, steps } }; +} + +function readMatch(raw: unknown): IChatModelFeedbackSurveyMatch | string { + if (raw !== undefined && !isObject(raw)) { + return 'match must be an object'; + } + const source = isObject(raw) ? raw : {}; + + const match: Record = {}; + for (const field of MATCH_FIELDS) { + const selectors = readSelectorList(source[field], `match.${field}`); + if (typeof selectors === 'string') { + return selectors; + } + match[field] = selectors; + } + + if (MATCH_FIELDS.every(field => match[field].length === 0)) { + return 'match must narrow at least one dimension'; + } + + return { + selectedModels: match.selectedModels, + resolvedModels: match.resolvedModels, + modes: match.modes, + harnesses: match.harnesses, + sessionTypes: match.sessionTypes, + }; +} + +function readSelectorList(raw: unknown, path: string): string[] | string { + if (raw === undefined) { + return []; + } + if (!Array.isArray(raw)) { + return `${path} must be an array of strings`; + } + if (raw.length > MAX_SELECTORS) { + return `${path} exceeds ${MAX_SELECTORS} entries`; + } + const out: string[] = []; + for (const entry of raw) { + if (typeof entry !== 'string') { + return `${path} must contain only strings`; + } + const normalized = normalizeSelector(entry); + if (!normalized) { + return `${path} must not contain empty strings`; + } + out.push(normalized); + } + return out; +} + +/** + * Reads the automatic prompting rules. An omitted `prompt` block gives a manual only survey, so + * an experiment that forgets to describe its pacing under prompts rather than nags. + */ +function readPrompt(raw: unknown): IChatModelFeedbackSurveyPrompt | string { + if (raw !== undefined && !isObject(raw)) { + return 'prompt must be an object'; + } + const source = isObject(raw) ? raw : {}; + + const cooldownDays = readNonNegativeNumber(source.cooldownDays, 7); + if (cooldownDays === undefined) { + return 'prompt.cooldownDays must be a non-negative number'; + } + + const maxPerSession = readPositiveInteger(source.maxPerSession, 1); + if (maxPerSession === undefined) { + return 'prompt.maxPerSession must be a positive integer'; + } + + const chance = readChance(source.chance); + if (typeof chance === 'string') { + return chance; + } + + const triggers = readTriggers(source.triggers); + if (typeof triggers === 'string') { + return triggers; + } + + return { cooldownDays, maxPerSession, chance, triggers }; +} + +function readChance(raw: unknown): IChatModelFeedbackSurveyChance | string { + if (raw !== undefined && !isObject(raw)) { + return 'prompt.chance must be an object'; + } + const source = isObject(raw) ? raw : {}; + + const initial = readProbability(source.initial, 0); + if (initial === undefined) { + return 'prompt.chance.initial must be a probability between 0 and 1'; + } + const increment = readProbability(source.increment, 0); + if (increment === undefined) { + return 'prompt.chance.increment must be a probability between 0 and 1'; + } + const max = readProbability(source.max, 1); + if (max === undefined) { + return 'prompt.chance.max must be a probability between 0 and 1'; + } + if (max < initial) { + return 'prompt.chance.max must be greater than or equal to prompt.chance.initial'; + } + + return { initial, increment, max }; +} + +function readTriggers(raw: unknown): IChatModelFeedbackSurveyTriggers | string { + if (raw !== undefined && !isObject(raw)) { + return 'prompt.triggers must be an object'; + } + const source = isObject(raw) ? raw : {}; + + const modelSwitchedAway = readTrigger(source.modelSwitchedAway, 'prompt.triggers.modelSwitchedAway'); + if (typeof modelSwitchedAway === 'string') { + return modelSwitchedAway; + } + + return { modelSwitchedAway }; +} + +function readTrigger(raw: unknown, path: string): IChatModelFeedbackSurveyTrigger | string { + if (raw === undefined) { + return { enabled: false, bypassCooldown: false }; + } + // `true` is accepted as shorthand for an enabled trigger that still respects the cooldown. + if (typeof raw === 'boolean') { + return { enabled: raw, bypassCooldown: false }; + } + if (!isObject(raw)) { + return `${path} must be a boolean or an object`; + } + if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') { + return `${path}.enabled must be a boolean`; + } + if (raw.bypassCooldown !== undefined && typeof raw.bypassCooldown !== 'boolean') { + return `${path}.bypassCooldown must be a boolean`; + } + return { enabled: raw.enabled ?? true, bypassCooldown: raw.bypassCooldown ?? false }; +} + +function readSteps(raw: unknown): ChatModelFeedbackSurveyStep[] | string { + if (!Array.isArray(raw) || raw.length === 0) { + return 'steps must be a non-empty array'; + } + if (raw.length > MAX_STEPS) { + return `steps exceeds ${MAX_STEPS} entries`; + } + + const steps: ChatModelFeedbackSurveyStep[] = []; + const seenIds = new Set(); + + for (let i = 0; i < raw.length; i++) { + const step = readStep(raw[i], i); + if (typeof step === 'string') { + return step; + } + if (seenIds.has(step.id)) { + return `steps[${i}].id "${step.id}" is duplicated`; + } + seenIds.add(step.id); + steps.push(step); + } + + // A text step is terminal because it carries the Submit button, so one in the middle would + // make every later step unreachable. + const textStepIndexes = steps.map((step, index) => step.kind === ChatModelFeedbackSurveyStepKind.Text ? index : -1).filter(index => index >= 0); + if (textStepIndexes.length > 1) { + return 'steps may contain at most one text step'; + } + if (textStepIndexes.length === 1 && textStepIndexes[0] !== steps.length - 1) { + return 'a text step must be the last step'; + } + + return steps; +} + +function readStep(raw: unknown, index: number): ChatModelFeedbackSurveyStep | string { + if (!isObject(raw)) { + return `steps[${index}] must be an object`; + } + + const id = readId(raw.id); + if (!id) { + return `steps[${index}].id is missing or malformed`; + } + + const title = readText(raw.title, MAX_TITLE_LENGTH); + if (!title) { + return `steps[${index}].title is missing or too long`; + } + + if (raw.kind === ChatModelFeedbackSurveyStepKind.Text) { + const placeholder = raw.placeholder === undefined ? undefined : readText(raw.placeholder, MAX_PLACEHOLDER_LENGTH); + if (raw.placeholder !== undefined && !placeholder) { + return `steps[${index}].placeholder is empty or too long`; + } + const requestedMaxLength = readPositiveInteger(raw.maxLength, MAX_COMMENT_LENGTH); + if (requestedMaxLength === undefined) { + return `steps[${index}].maxLength must be a positive integer`; + } + return { + kind: ChatModelFeedbackSurveyStepKind.Text, + id, + title, + placeholder, + maxLength: Math.min(requestedMaxLength, MAX_COMMENT_LENGTH), + }; + } + + if (raw.kind !== ChatModelFeedbackSurveyStepKind.Choice) { + return `steps[${index}].kind must be "${ChatModelFeedbackSurveyStepKind.Choice}" or "${ChatModelFeedbackSurveyStepKind.Text}"`; + } + + if (!Array.isArray(raw.options) || raw.options.length < MIN_OPTIONS || raw.options.length > MAX_OPTIONS) { + return `steps[${index}].options must have between ${MIN_OPTIONS} and ${MAX_OPTIONS} entries`; + } + + const options: IChatModelFeedbackSurveyOption[] = []; + const seenOptionIds = new Set(); + for (let i = 0; i < raw.options.length; i++) { + const option = raw.options[i]; + if (!isObject(option)) { + return `steps[${index}].options[${i}] must be an object`; + } + const optionId = readId(option.id); + if (!optionId) { + return `steps[${index}].options[${i}].id is missing or malformed`; + } + if (seenOptionIds.has(optionId)) { + return `steps[${index}].options[${i}].id "${optionId}" is duplicated`; + } + const label = readText(option.label, MAX_LABEL_LENGTH); + if (!label) { + return `steps[${index}].options[${i}].label is missing or too long`; + } + seenOptionIds.add(optionId); + options.push({ id: optionId, label }); + } + + return { kind: ChatModelFeedbackSurveyStepKind.Choice, id, title, options }; +} + +function readId(raw: unknown): string | undefined { + if (typeof raw !== 'string') { + return undefined; + } + const trimmed = raw.trim().toLowerCase(); + if (!trimmed || trimmed.length > MAX_ID_LENGTH || !ID_PATTERN.test(trimmed)) { + return undefined; + } + return trimmed; +} + +function readText(raw: unknown, maxLength: number): string | undefined { + if (typeof raw !== 'string') { + return undefined; + } + const trimmed = raw.trim(); + if (!trimmed || trimmed.length > maxLength) { + return undefined; + } + return trimmed; +} + +function readPositiveInteger(raw: unknown, fallback: number): number | undefined { + if (raw === undefined) { + return fallback; + } + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) { + return undefined; + } + return raw; +} + +function readNonNegativeNumber(raw: unknown, fallback: number): number | undefined { + if (raw === undefined) { + return fallback; + } + if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) { + return undefined; + } + return raw; +} + +function readProbability(raw: unknown, fallback: number): number | undefined { + if (raw === undefined) { + return fallback; + } + if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0 || raw > 1) { + return undefined; + } + return raw; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Builds every string a selector may match for a model. + * + * Identifiers are qualified differently across harnesses. The language model service uses + * `//` while agent host sessions use `:`, so a selector is + * compared against each segment as well as the whole id. That lets `auto` match both + * `copilot/auto` and `agent-host-copilotcli:auto`. + */ +export function expandModelMatchCandidates(modelId: string | undefined, aliases?: readonly string[]): Set { + const candidates = new Set(); + const add = (value: string | undefined): void => { + const normalized = value === undefined ? '' : normalizeSelector(value); + if (normalized) { + candidates.add(normalized); + } + }; + + if (modelId) { + add(modelId); + for (const segment of modelId.split(/[/:]/)) { + add(segment); + } + } + for (const alias of aliases ?? []) { + add(alias); + } + + return candidates; +} + +/** Whether the response described by `context` should be offered `config`'s survey. */ +export function matchesChatModelFeedbackSurvey(config: IChatModelFeedbackSurveyConfig, context: IChatModelFeedbackSurveyMatchContext): boolean { + const { match } = config; + + if (match.selectedModels.length) { + const candidates = expandModelMatchCandidates(context.selectedModelId, context.selectedModelAliases); + if (!match.selectedModels.some(selector => candidates.has(selector))) { + return false; + } + } + + if (match.resolvedModels.length) { + const candidates = expandModelMatchCandidates(context.resolvedModelId); + if (!match.resolvedModels.some(selector => candidates.has(selector))) { + return false; + } + } + + if (match.modes.length && !matchesScalar(match.modes, context.modeId)) { + return false; + } + + if (match.harnesses.length && !matchesScalar(match.harnesses, context.harness ?? CHAT_MODEL_FEEDBACK_SURVEY_NO_HARNESS)) { + return false; + } + + if (match.sessionTypes.length && !matchesScalar(match.sessionTypes, context.sessionType)) { + return false; + } + + return true; +} + +function matchesScalar(selectors: readonly string[], value: string | undefined): boolean { + const normalized = value === undefined ? undefined : normalizeSelector(value); + return !!normalized && selectors.includes(normalized); +} + +function normalizeSelector(value: string): string { + return value.trim().toLowerCase().replace(/[\s_]+/g, '-'); +} diff --git a/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.ts b/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.ts new file mode 100644 index 0000000000000..2edeb8ddef31c --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Wire contract between the workbench and the Copilot extension for survey telemetry. + * + * Answers must reach GitHub restricted telemetry, which only the Copilot extension can send to. + * A command is used rather than a data channel because `executeCommand` activates the extension, + * so results produced before activation are not dropped. Command ids are first come first + * served, so this routes the payload rather than securing it. Keep the shape in sync with + * `chatModelFeedbackSurveyForwardingContrib.ts` in the extension. + */ +export const CHAT_MODEL_FEEDBACK_SURVEY_TELEMETRY_COMMAND_ID = '_github.copilot.chat.reportModelFeedbackSurvey'; + +export type ChatModelFeedbackSurveyEventKind = + /** The pill became available on a response. */ + | 'shown' + /** The user opened the survey panel. */ + | 'opened' + /** A step was answered. Sent as it happens so abandoned surveys still report. */ + | 'step' + /** The user submitted on the final step. */ + | 'submitted' + /** The user dismissed the survey without submitting. */ + | 'dismissed'; + +export interface IChatModelFeedbackSurveyTelemetryEvent { + readonly kind: ChatModelFeedbackSurveyEventKind; + readonly surveyId: string; + /** Stitches the events for one survey together. Minted when the survey first applies. */ + readonly surveyInstanceId: string; + readonly stepCount: number; + /** What opened the survey, so asked for and unprompted surveys can be measured apart. */ + readonly trigger?: 'manual' | 'chance' | 'modelSwitchedAway'; + readonly stepId?: string; + readonly stepIndex?: number; + /** The chosen option id for a `choice` step. Always one of the configured option ids. */ + readonly answerId?: string; + /** Free text from a text step. Must only reach GitHub restricted telemetry, never `publicLog2`. */ + readonly comment?: string; + readonly modelId?: string; + readonly resolvedModelId?: string; + readonly modeId?: string; + readonly harness?: string; + readonly sessionType?: string; + readonly requestId: string; +} diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index a6ed9382dc127..7e094509e441b 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -246,6 +246,8 @@ export interface IChatResponseViewModel { readonly isComplete: boolean; readonly isCanceled: boolean; readonly isStale: boolean; + /** Whether this is the last row in the transcript. */ + readonly isLast: boolean; readonly vote: ChatAgentVoteDirection | undefined; readonly replyFollowups?: IChatFollowup[]; readonly errorDetails?: IChatResponseErrorDetails; diff --git a/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyActions.test.ts new file mode 100644 index 0000000000000..5ffbf289b347d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyActions.test.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { IAction } from '../../../../../../base/common/actions.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatModelFeedbackSurveyActionId, focusChatModelFeedbackSurveyAction, IFeedbackSurveyToolBar } from '../../../browser/actions/chatModelFeedbackSurveyActions.js'; + +suite('ChatModelFeedbackSurveyActions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createToolBar(actionIds: readonly string[]): IFeedbackSurveyToolBar & { focused: number | undefined } { + return { + focused: undefined, + getItemsLength: () => actionIds.length, + getItemAction: (index: number) => ({ id: actionIds[index] } as IAction), + focus(index?: number) { this.focused = index; }, + }; + } + + test('focuses the feedback control rather than whichever action comes first', () => { + // The copy action sits before the survey control in the response footer. + const toolbar = createToolBar(['workbench.action.chat.copyItem', ChatModelFeedbackSurveyActionId, 'workbench.action.chat.reportIssueForBug']); + + focusChatModelFeedbackSurveyAction(toolbar); + + assert.strictEqual(toolbar.focused, 1); + }); + + test('falls back to the toolbar when the control is not shown', () => { + const toolbar = createToolBar(['workbench.action.chat.copyItem']); + + focusChatModelFeedbackSurveyAction(toolbar); + + assert.strictEqual(toolbar.focused, undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.test.ts new file mode 100644 index 0000000000000..9764b785d1763 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.test.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ChatModelFeedbackSurveyPromptContribution } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyPromptContribution.js'; +import { IChatModelFeedbackSurveyService } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; +import { MockChatModelFeedbackSurveyService } from './mockChatModelFeedbackSurveyService.js'; + +suite('ChatModelFeedbackSurveyPromptContribution', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionOne = URI.parse('vscode-chat-editor://session-1'); + const sessionTwo = URI.parse('vscode-chat-editor://session-2'); + + function createHarness() { + const switches: { from: string; to: string; session: string }[] = []; + const selectedModel = observableValue('selectedModel', undefined); + const onDidChangeViewModel = store.add(new Emitter()); + let sessionResource: URI | undefined; + + const widget = { + input: { selectedLanguageModel: selectedModel }, + onDidChangeViewModel: onDidChangeViewModel.event, + get viewModel() { return sessionResource ? { sessionResource } : undefined; }, + } as unknown as IChatWidget; + + const surveyService = new MockChatModelFeedbackSurveyService(); + surveyService.notifyModelSwitchedAway = (session, from, to) => { + switches.push({ from, to, session: session.toString() }); + }; + + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IChatWidgetService, { + getAllWidgets: () => [widget], + onDidAddWidget: Event.None, + onDidRemoveWidget: Event.None, + } as unknown as IChatWidgetService); + instantiationService.stub(IChatModelFeedbackSurveyService, surveyService); + store.add(instantiationService.createInstance(ChatModelFeedbackSurveyPromptContribution)); + + return { + switches, + selectModel: (identifier: string) => selectedModel.set({ identifier } as ILanguageModelChatMetadataAndIdentifier, undefined), + loadSession: (resource: URI | undefined) => { + sessionResource = resource; + onDidChangeViewModel.fire(); + }, + }; + } + + test('reports the user moving from one model to another', () => { + const harness = createHarness(); + harness.loadSession(sessionOne); + + harness.selectModel('copilot/auto'); + harness.selectModel('copilot/gpt-5.2'); + + assert.deepStrictEqual(harness.switches, [{ from: 'copilot/auto', to: 'copilot/gpt-5.2', session: sessionOne.toString() }]); + }); + + test('reports a switch when the model resolved before the session finished loading', () => { + // A widget registers, then resolves its model, then loads the session. The switch that + // follows is still the user rejecting the model. + const harness = createHarness(); + harness.selectModel('copilot/auto'); + harness.loadSession(sessionOne); + + harness.selectModel('copilot/gpt-5.2'); + + assert.deepStrictEqual(harness.switches, [{ from: 'copilot/auto', to: 'copilot/gpt-5.2', session: sessionOne.toString() }]); + }); + + test('ignores the model that comes with a newly loaded session', () => { + const harness = createHarness(); + harness.loadSession(sessionOne); + harness.selectModel('copilot/auto'); + + // Switching sessions restores that session's model, which is not the user rejecting one. + harness.loadSession(sessionTwo); + harness.selectModel('copilot/gpt-5.2'); + + assert.deepStrictEqual(harness.switches, []); + }); + + test('keeps reporting switches made after a session change', () => { + const harness = createHarness(); + harness.loadSession(sessionOne); + harness.selectModel('copilot/auto'); + harness.loadSession(sessionTwo); + harness.selectModel('copilot/auto'); + + harness.selectModel('copilot/gpt-5.2'); + + assert.deepStrictEqual(harness.switches, [{ from: 'copilot/auto', to: 'copilot/gpt-5.2', session: sessionTwo.toString() }]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyService.test.ts b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyService.test.ts new file mode 100644 index 0000000000000..3030c14d9a22b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyService.test.ts @@ -0,0 +1,551 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.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 { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { ITelemetryService, TelemetryLevel } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IAssignmentFilter, IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; +import { ChatModelFeedbackSurveyService, ChatModelFeedbackSurveyStatus } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; +import { CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION } from '../../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; +import { IChatModelFeedbackSurveyTelemetryEvent } from '../../../common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; + +suite('ChatModelFeedbackSurveyService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const steps = [ + { kind: 'choice', id: 'routing', title: 'Right model?', options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }] }, + { kind: 'text', id: 'comments', title: 'Anything else?', maxLength: 200 }, + ]; + + /** Probabilities use their boundaries so no random source needs stubbing. */ + function makePayload(prompt: object = {}): string { + return JSON.stringify({ + version: CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION, + id: 'auto-routing', + match: { selectedModels: ['auto'] }, + prompt, + steps, + }); + } + + /** Manual-only: the control is available but the survey never surfaces on its own. */ + const payload = makePayload(); + + /** A treatment that resolves but yields no config, as a malformed or retired one would. */ + const UNUSABLE_PAYLOAD = '{}'; + + const defaultSession = URI.parse('vscode-chat-editor://session-1'); + + /** Responses default to being the newest row, which is the only one that offers a survey. */ + function createResponse(requestId: string, options?: { modelId?: string; sessionResource?: URI; isComplete?: boolean; isLast?: boolean }): IChatResponseViewModel { + return { + requestId, + sessionResource: options?.sessionResource ?? defaultSession, + isComplete: options?.isComplete ?? true, + isLast: options?.isLast ?? true, + isCanceled: false, + errorDetails: undefined, + result: undefined, + model: { request: { modelId: options?.modelId ?? 'copilot/auto', modeInfo: { telemetryModeId: 'agent' } } }, + } as unknown as IChatResponseViewModel; + } + + async function createService(options: { + treatment?: string; + feedbackEnabled?: boolean; + onDidRefetchAssignments?: Event; + getTreatment?: () => string | undefined; + } = {}) { + const events: IChatModelFeedbackSurveyTelemetryEvent[] = []; + const instantiationService = disposables.add(new TestInstantiationService()); + + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration('telemetry', { feedback: { enabled: options.feedbackEnabled ?? true } }); + + instantiationService.stub(IWorkbenchAssignmentService, { + _serviceBrand: undefined, + onDidRefetchAssignments: options.onDidRefetchAssignments ?? Event.None, + getCurrentExperiments: async () => [], + addTelemetryAssignmentFilter(_filter: IAssignmentFilter): void { }, + getTreatment: async () => (options.getTreatment + ? options.getTreatment() + : options.treatment ?? payload) as T | undefined, + } satisfies IWorkbenchAssignmentService); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(ITelemetryService, { telemetryLevel: TelemetryLevel.USAGE } as ITelemetryService); + instantiationService.stub(ICommandService, { + executeCommand: async (_id: string, event: IChatModelFeedbackSurveyTelemetryEvent) => { events.push(event); }, + } as unknown as ICommandService); + instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined } as unknown as ILanguageModelsService); + instantiationService.stub(IChatSessionsService, { getChatSessionContribution: () => undefined } as unknown as IChatSessionsService); + const disposeSession = disposables.add(new Emitter<{ readonly sessionResources: readonly URI[]; readonly reason: 'cleared' }>()); + instantiationService.stub(IChatService, { onDidDisposeSession: disposeSession.event } as unknown as IChatService); + instantiationService.stub(ILogService, new NullLogService()); + + const service = disposables.add(instantiationService.createInstance(ChatModelFeedbackSurveyService)); + await new Promise(resolve => setTimeout(resolve, 0)); // let the treatment resolve + return { service, events, disposeSession, configurationService }; + } + + test('offers a matching response one stable survey and reports it as shown once', async () => { + const { service, events } = await createService(); + const response = createResponse('req-1'); + + const first = service.getSurvey(response); + const second = service.getSurvey(response); + + assert.deepStrictEqual({ + offered: !!first, + status: first?.status, + stableInstance: first?.instanceId === second?.instanceId, + reportedKinds: events.map(e => e.kind), + }, { + offered: true, + status: ChatModelFeedbackSurveyStatus.Collapsed, + stableInstance: true, + reportedKinds: ['shown'], + }); + }); + + test('withholds the survey when the response does not qualify', async () => { + const { service } = await createService(); + + assert.deepStrictEqual({ + wrongModel: service.getSurvey(createResponse('req-1', { modelId: 'copilot/gpt-5.2' })), + stillStreaming: service.getSurvey(createResponse('req-2', { isComplete: false })), + }, { + wrongModel: undefined, + stillStreaming: undefined, + }); + }); + + test('reports each step as it is answered so an abandoned survey still yields data', async () => { + const { service, events } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + + service.toggle(response); + service.answerChoice(response, 'routing', 'yes'); + service.setCommentDraft(response, 'some thoughts'); + service.dismiss(response); + + assert.deepStrictEqual(events.map(e => ({ kind: e.kind, stepId: e.stepId, answerId: e.answerId, comment: e.comment })), [ + { kind: 'shown', stepId: undefined, answerId: undefined, comment: undefined }, + { kind: 'opened', stepId: undefined, answerId: undefined, comment: undefined }, + { kind: 'step', stepId: 'routing', answerId: 'yes', comment: undefined }, + { kind: 'step', stepId: 'comments', answerId: undefined, comment: 'some thoughts' }, + { kind: 'dismissed', stepId: undefined, answerId: undefined, comment: undefined }, + ]); + }); + + test('ignores answers that are not configured options', async () => { + const { service, events } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + service.toggle(response); + + service.answerChoice(response, 'routing', 'injected-value'); + + assert.deepStrictEqual(events.map(e => e.kind), ['shown', 'opened']); + }); + + test('acknowledges an answered response instead of re-asking, without removing the control', async () => { + const { service, events } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + service.toggle(response); + service.submit(response, 'done'); + + const afterSubmit = service.getSurvey(response); + service.dismiss(response); // close the acknowledgement + service.toggle(response); // and reopen it + + assert.deepStrictEqual({ + stillAvailable: !!afterSubmit, + isSubmitted: afterSubmit?.isSubmitted, + reopened: service.getSurvey(response)?.isSubmitted, + // Reopening an answered survey must not inflate the funnel. + opens: events.filter(e => e.kind === 'opened').length, + submissions: events.filter(e => e.kind === 'submitted').length, + dismissals: events.filter(e => e.kind === 'dismissed').length, + }, { + stillAvailable: true, + isSubmitted: true, + reopened: true, + opens: 1, + submissions: 1, + dismissals: 0, + }); + }); + + test('offers the survey only on the newest response', async () => { + const { service } = await createService(); + + const older = createResponse('req-1', { isLast: false }); + const newest = createResponse('req-2'); + + assert.deepStrictEqual({ + older: service.getSurvey(older), + newest: !!service.getSurvey(newest), + }, { + older: undefined, + newest: true, + }); + }); + + test('drops the control from a response once a newer one arrives', async () => { + const { service } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + + const superseded = createResponse('req-1', { isLast: false }); + + assert.strictEqual(service.getSurvey(superseded), undefined); + }); + + test('keeps a part answered survey alive after it is superseded', async () => { + const { service } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + service.toggle(response); + service.answerChoice(response, 'routing', 'yes'); + + // The user is mid answer, so a newer response must not pull the form away. + const superseded = createResponse('req-1', { isLast: false }); + + assert.deepStrictEqual(service.getSurvey(superseded)?.status, ChatModelFeedbackSurveyStatus.Open); + }); + + test('opening a survey closes the one already showing', async () => { + const { service, events } = await createService(); + const first = createResponse('req-1'); + service.getSurvey(first); + service.toggle(first); + + const second = createResponse('req-2'); + service.getSurvey(second); + service.toggle(second); + + assert.deepStrictEqual({ + // Closed by the second opening, then dropped because it is no longer the newest row. + first: service.getSurvey(createResponse('req-1', { isLast: false })), + second: service.getSurvey(second)?.status, + // Being superseded is the UI moving on, so it is not reported as a dismissal. + dismissals: events.filter(e => e.kind === 'dismissed').length, + }, { + first: undefined, + second: ChatModelFeedbackSurveyStatus.Open, + dismissals: 0, + }); + }); + + test('leaves other sessions alone when a new response supersedes one', async () => { + const { service, events } = await createService(); + const otherSession = URI.parse('vscode-chat-editor://session-2'); + + const other = createResponse('other-1', { sessionResource: otherSession }); + const otherInstance = service.getSurvey(other)?.instanceId; + + // A new response in the first session must not evict the second session's state. + service.getSurvey(createResponse('req-1')); + service.getSurvey(createResponse('req-2')); + + assert.deepStrictEqual({ + sameInstance: service.getSurvey(other)?.instanceId === otherInstance, + shownForOther: events.filter(e => e.kind === 'shown' && e.requestId === 'other-1').length, + }, { + sameInstance: true, + shownForOther: 1, + }); + }); + + test('does not let an automatic prompt displace a survey being answered', async () => { + const { service } = await createService({ treatment: makePayload({ chance: { initial: 1 }, maxPerSession: 5, cooldownDays: 0 }) }); + + const first = createResponse('req-1'); + service.getSurvey(first); + service.answerChoice(first, 'routing', 'yes'); + + // The next response would normally auto open, but the user is mid answer. + const second = createResponse('req-2'); + + assert.deepStrictEqual({ + first: service.getSurvey(createResponse('req-1', { isLast: false }))?.status, + second: service.getSurvey(second)?.status, + }, { + first: ChatModelFeedbackSurveyStatus.Open, + second: ChatModelFeedbackSurveyStatus.Collapsed, + }); + }); + + test('stops a stale response being prompted once a newer one arrives', async () => { + const { service } = await createService({ treatment: makePayload({ chance: { initial: 0 }, triggers: { modelSwitchedAway: true } }) }); + const surveyed = createResponse('req-1'); + service.getSurvey(surveyed); + + // A newer response the survey does not match still supersedes the old one. + service.getSurvey(createResponse('req-2', { modelId: 'copilot/gpt-5.2' })); + service.notifyModelSwitchedAway(defaultSession, 'copilot/auto', 'copilot/gpt-5.2'); + + assert.strictEqual(service.getSurvey(createResponse('req-1', { isLast: false })), undefined); + }); + + test('toggles the survey closed when the control is pressed again', async () => { + const { service, events } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + + service.toggle(response); + const opened = service.getSurvey(response)?.status; + service.toggle(response); + const closed = service.getSurvey(response)?.status; + service.toggle(response); + + assert.deepStrictEqual({ + opened, + closed, + reopened: service.getSurvey(response)?.status, + kinds: events.map(e => e.kind), + }, { + opened: ChatModelFeedbackSurveyStatus.Open, + closed: ChatModelFeedbackSurveyStatus.Collapsed, + reopened: ChatModelFeedbackSurveyStatus.Open, + // Closing by the control is a dismissal, exactly as the X and Escape are. + kinds: ['shown', 'opened', 'dismissed', 'opened'], + }); + }); + + test('toggling an acknowledgement closed does not report a second dismissal', async () => { + const { service, events } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + service.toggle(response); + service.submit(response, 'done'); + + service.toggle(response); // hide the acknowledgement + const hidden = service.getSurvey(response)?.status; + service.toggle(response); // and show it again + + assert.deepStrictEqual({ + hidden, + shownAgain: service.getSurvey(response)?.isSubmitted, + dismissals: events.filter(e => e.kind === 'dismissed').length, + opens: events.filter(e => e.kind === 'opened').length, + }, { + hidden: ChatModelFeedbackSurveyStatus.Collapsed, + shownAgain: true, + dismissals: 0, + opens: 1, + }); + }); + + test('stops offering a survey once feedback is switched off', async () => { + const { service, configurationService } = await createService(); + const response = createResponse('req-1'); + const offered = !!service.getSurvey(response); + + configurationService.setUserConfiguration('telemetry', { feedback: { enabled: false } }); + + assert.deepStrictEqual({ offered, afterDisabling: service.getSurvey(response) }, { offered: true, afterDisabling: undefined }); + }); + + test('releases what it held for a session once that session goes away', async () => { + const { service, disposeSession } = await createService(); + const response = createResponse('req-1'); + const first = service.getSurvey(response)?.instanceId; + + disposeSession.fire({ sessionResources: [defaultSession], reason: 'cleared' }); + + // A fresh instance means the entry really was released rather than reused. + assert.notStrictEqual(service.getSurvey(createResponse('req-1'))?.instanceId, first); + }); + + test('keeps the control on the newest response even after the prompt budget is spent', async () => { + // Pacing governs unprompted surfacing only, so manual feedback is never rationed. + const { service } = await createService({ treatment: makePayload({ chance: { initial: 1 }, maxPerSession: 1 }) }); + + const first = service.getSurvey(createResponse('req-1')); + const second = service.getSurvey(createResponse('req-2')); + const third = service.getSurvey(createResponse('req-3')); + + assert.deepStrictEqual({ + available: [!!first, !!second, !!third], + autoOpened: [first?.status, second?.status, third?.status], + }, { + available: [true, true, true], + autoOpened: [ + ChatModelFeedbackSurveyStatus.Open, // the one automatic prompt this session allows + ChatModelFeedbackSurveyStatus.Collapsed, + ChatModelFeedbackSurveyStatus.Collapsed, + ], + }); + }); + + test('manual activation always opens, whatever the prompting rules say', async () => { + // One automatic prompt per session and a year long cooldown, both of which the first + // response consumes so the second is left with no automatic budget at all. + const { service, events } = await createService({ treatment: makePayload({ chance: { initial: 1 }, maxPerSession: 1, cooldownDays: 365 }) }); + + const prompted = createResponse('req-1'); + const autoStatus = service.getSurvey(prompted)?.status; + + const second = createResponse('req-2'); + const beforeManual = service.getSurvey(second)?.status; + service.toggle(second); + + assert.deepStrictEqual({ + autoStatus, + beforeManual, + afterManual: service.getSurvey(second)?.status, + triggers: events.filter(e => e.kind === 'opened').map(e => e.trigger), + }, { + autoStatus: ChatModelFeedbackSurveyStatus.Open, + beforeManual: ChatModelFeedbackSurveyStatus.Collapsed, + afterManual: ChatModelFeedbackSurveyStatus.Open, + triggers: ['chance', 'manual'], + }); + }); + + test('ramps the odds with each response that passes without prompting', async () => { + // An increment of 1 makes the ramp observable without stubbing random. The first response + // has probability 0 and the second, after one miss, has probability 1. + const { service } = await createService({ treatment: makePayload({ chance: { initial: 0, increment: 1 }, maxPerSession: 5, cooldownDays: 0 }) }); + + assert.deepStrictEqual([ + service.getSurvey(createResponse('req-1'))?.status, + service.getSurvey(createResponse('req-2'))?.status, + ], [ + ChatModelFeedbackSurveyStatus.Collapsed, + ChatModelFeedbackSurveyStatus.Open, + ]); + }); + + test('prompts on switching away from the surveyed model, and only within the trigger rules', async () => { + const enabled = await createService({ treatment: makePayload({ chance: { initial: 0 }, triggers: { modelSwitchedAway: true } }) }); + const disabled = await createService({ treatment: makePayload({ chance: { initial: 0 } }) }); + + const enabledResponse = createResponse('req-1'); + enabled.service.getSurvey(enabledResponse); + enabled.service.notifyModelSwitchedAway(defaultSession, 'copilot/auto', 'copilot/gpt-5.2'); + + const disabledResponse = createResponse('req-1'); + disabled.service.getSurvey(disabledResponse); + disabled.service.notifyModelSwitchedAway(defaultSession, 'copilot/auto', 'copilot/gpt-5.2'); + + assert.deepStrictEqual({ + enabled: enabled.service.getSurvey(enabledResponse)?.status, + enabledTrigger: enabled.events.filter(e => e.kind === 'opened').map(e => e.trigger), + disabled: disabled.service.getSurvey(disabledResponse)?.status, + }, { + enabled: ChatModelFeedbackSurveyStatus.Open, + enabledTrigger: ['modelSwitchedAway'], + disabled: ChatModelFeedbackSurveyStatus.Collapsed, + }); + }); + + test('ignores model switches that are not away from the surveyed model', async () => { + const { service } = await createService({ treatment: makePayload({ chance: { initial: 0 }, triggers: { modelSwitchedAway: true } }) }); + + const unrelated = createResponse('req-1'); + service.getSurvey(unrelated); + // A switch between two unsurveyed models says nothing about Auto, even though an Auto + // response is still the most recent surveyed one here. + service.notifyModelSwitchedAway(defaultSession, 'copilot/gpt-5.2', 'copilot/claude-sonnet-4.5'); + const afterUnrelated = service.getSurvey(unrelated)?.status; + + // Moving between two surveyed models is not abandoning the thing being surveyed. + service.notifyModelSwitchedAway(defaultSession, 'copilot/auto', 'agent-host-copilotcli:auto'); + + assert.deepStrictEqual({ + afterUnrelated, + afterMatchedToMatched: service.getSurvey(unrelated)?.status, + }, { + afterUnrelated: ChatModelFeedbackSurveyStatus.Collapsed, + afterMatchedToMatched: ChatModelFeedbackSurveyStatus.Collapsed, + }); + }); + + test('completes a survey whose last step is a choice, since it has no submit button', async () => { + const choiceOnly = JSON.stringify({ + version: CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION, + id: 'choice-only', + match: { selectedModels: ['auto'] }, + steps: [{ kind: 'choice', id: 'routing', title: 'Right model?', options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }] }], + }); + const { service, events } = await createService({ treatment: choiceOnly }); + const response = createResponse('req-1'); + service.getSurvey(response); + service.toggle(response); + + service.answerChoice(response, 'routing', 'yes'); + + assert.deepStrictEqual({ + kinds: events.map(e => e.kind), + // The survey is over, so it acknowledges rather than asking again. + isSubmitted: service.getSurvey(response)?.isSubmitted, + }, { + kinds: ['shown', 'opened', 'step', 'submitted'], + isSubmitted: true, + }); + }); + + test('keeps an open survey and its budget when the treatment stops resolving to a usable config', async () => { + const refetch = new Emitter(); + const treatments: (string | undefined)[] = [payload, UNUSABLE_PAYLOAD]; + const { service } = await createService({ onDidRefetchAssignments: refetch.event, getTreatment: () => treatments.shift() }); + const response = createResponse('req-1'); + service.getSurvey(response); + service.toggle(response); + + refetch.fire(); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual(service.getSurvey(response)?.status, ChatModelFeedbackSurveyStatus.Open); + refetch.dispose(); + }); + + test('preserves an uncommitted comment draft across a widget recycle', async () => { + const { service } = await createService(); + const response = createResponse('req-1'); + service.getSurvey(response); + service.toggle(response); + service.answerChoice(response, 'routing', 'yes'); + + service.setCommentDraft(response, 'half typed'); + + assert.strictEqual(service.getSurvey(response)?.commentDraft, 'half typed'); + }); + + test('collects nothing without a usable experiment or with feedback disabled', async () => { + const unconfigured = await createService({ treatment: UNUSABLE_PAYLOAD }); + const feedbackOff = await createService({ feedbackEnabled: false }); + + assert.deepStrictEqual({ + unconfiguredSurvey: unconfigured.service.getSurvey(createResponse('req-1')), + unconfiguredEvents: unconfigured.events, + feedbackOffSurvey: feedbackOff.service.getSurvey(createResponse('req-1')), + feedbackOffEvents: feedbackOff.events, + }, { + unconfiguredSurvey: undefined, + unconfiguredEvents: [], + feedbackOffSurvey: undefined, + feedbackOffEvents: [], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts new file mode 100644 index 0000000000000..2156c6fe4f100 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts @@ -0,0 +1,252 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as dom from '../../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { ChatModelFeedbackSurveyWidget } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyWidget.js'; +import { ChatModelFeedbackSurveyStatus, IChatModelFeedbackSurveyChangeEvent, IChatModelFeedbackSurveyService, IChatModelFeedbackSurveyState } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { ChatModelFeedbackSurveyStepKind, IChatModelFeedbackSurveyConfig } from '../../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; +import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; + +suite('ChatModelFeedbackSurveyWidget', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const config = { + version: 1, + id: 'auto-routing', + match: { selectedModels: ['auto'], resolvedModels: [], modes: [], harnesses: [], sessionTypes: [] }, + prompt: { cooldownDays: 0, maxPerSession: 1, chance: { initial: 0, increment: 0, max: 1 }, triggers: { modelSwitchedAway: { enabled: false, bypassCooldown: false } } }, + steps: [{ + kind: ChatModelFeedbackSurveyStepKind.Choice, + id: 'routing', + title: 'Right model?', + options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }, { id: 'maybe', label: 'Maybe' }], + }], + } as IChatModelFeedbackSurveyConfig; + + const response = { + requestId: 'req-1', + sessionResource: URI.parse('vscode-chat-editor://session-1'), + isLast: true, + } as IChatResponseViewModel; + + interface ISurveyHarness { + readonly container: HTMLElement; + readonly answers: { stepId: string; optionId: string }[]; + readonly dismissals: number; + readonly focusRestores: number; + readonly state: { current: IChatModelFeedbackSurveyState }; + rerender(): void; + } + + function createWidget(options?: { + openTrigger?: 'manual' | 'chance'; + isSubmitted?: boolean; + onGetSurvey?: () => void; + onDidChangeSurveyState?: Event; + }): ISurveyHarness { + const answers: { stepId: string; optionId: string }[] = []; + const counts = { dismissals: 0, focusRestores: 0 }; + const state = { + current: { + config, + instanceId: 'instance-1', + status: ChatModelFeedbackSurveyStatus.Open, + stepIndex: 0, + answers: new Map(), + commentDraft: '', + isSubmitted: options?.isSubmitted ?? false, + openTrigger: options?.openTrigger ?? 'manual', + } satisfies IChatModelFeedbackSurveyState, + }; + + const surveyService: IChatModelFeedbackSurveyService = { + _serviceBrand: undefined, + onDidChangeSurveyState: options?.onDidChangeSurveyState ?? Event.None, + onDidChangeConfiguration: Event.None, + getSurvey: () => { + options?.onGetSurvey?.(); + return state.current; + }, + toggle: () => { }, + notifyModelSwitchedAway: () => { }, + answerChoice: (_response, stepId, optionId) => { answers.push({ stepId, optionId }); }, + submit: () => { }, + dismiss: () => { counts.dismissals++; }, + setCommentDraft: () => { }, + }; + + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IChatModelFeedbackSurveyService, surveyService); + + const container = dom.$('.chat-feedback-survey-widget'); + mainWindow.document.body.appendChild(container); + store.add({ dispose: () => container.remove() }); + + const widget = store.add(instantiationService.createInstance(ChatModelFeedbackSurveyWidget, container, () => { counts.focusRestores++; })); + widget.render(response); + return { + container, + answers, + state, + get dismissals() { return counts.dismissals; }, + get focusRestores() { return counts.focusRestores; }, + rerender: () => widget.render(response), + }; + } + + /** Browser key codes, which is what `StandardKeyboardEvent` reads and maps. */ + const enum BrowserKey { + Enter = 13, + End = 35, + Home = 36, + ArrowUp = 38, + ArrowDown = 40, + Space = 32, + Escape = 27, + } + + function pressKey(target: HTMLElement, keyCode: BrowserKey, key: string): void { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + Object.defineProperty(event, 'keyCode', { get: () => keyCode }); + target.dispatchEvent(event); + } + + function activeLabel(container: HTMLElement): string | undefined { + return container.querySelector('.chat-feedback-survey-list-item.active')?.textContent ?? undefined; + } + + test('moves the active option with the arrow keys and answers on Enter', () => { + const { container, answers } = createWidget(); + const list = container.querySelector('.chat-feedback-survey-list')!; + + const initial = activeLabel(container); + pressKey(list, BrowserKey.ArrowDown, 'ArrowDown'); + const afterDown = activeLabel(container); + pressKey(list, BrowserKey.Enter, 'Enter'); + + assert.deepStrictEqual({ initial, afterDown, answers }, { + initial: 'Yes', + afterDown: 'No', + answers: [{ stepId: 'routing', optionId: 'no' }], + }); + }); + + test('wraps around the ends and supports Home and End', () => { + const { container } = createWidget(); + const list = container.querySelector('.chat-feedback-survey-list')!; + + pressKey(list, BrowserKey.ArrowUp, 'ArrowUp'); + const afterUpFromFirst = activeLabel(container); + pressKey(list, BrowserKey.Home, 'Home'); + const afterHome = activeLabel(container); + pressKey(list, BrowserKey.End, 'End'); + + assert.deepStrictEqual({ afterUpFromFirst, afterHome, afterEnd: activeLabel(container) }, { + afterUpFromFirst: 'Maybe', + afterHome: 'Yes', + afterEnd: 'Maybe', + }); + }); + + test('keeps navigation keys away from the chat list once the survey has used them', () => { + const { container } = createWidget(); + const list = container.querySelector('.chat-feedback-survey-list')!; + + let reachedAncestor = false; + store.add(dom.addDisposableListener(container.parentElement!, dom.EventType.KEY_DOWN, () => { reachedAncestor = true; })); + + pressKey(list, BrowserKey.ArrowDown, 'ArrowDown'); + + assert.deepStrictEqual({ reachedAncestor, active: activeLabel(container) }, { reachedAncestor: false, active: 'No' }); + }); + + test('marks the active option as selected for screen readers', () => { + const { container } = createWidget(); + const list = container.querySelector('.chat-feedback-survey-list')!; + + const initial = [...container.querySelectorAll('.chat-feedback-survey-list-item')].map(i => i.getAttribute('aria-selected')); + pressKey(list, BrowserKey.ArrowDown, 'ArrowDown'); + const afterDown = [...container.querySelectorAll('.chat-feedback-survey-list-item')].map(i => i.getAttribute('aria-selected')); + + assert.deepStrictEqual({ initial, afterDown, activeDescendant: list.getAttribute('aria-activedescendant') }, { + initial: ['true', 'false', 'false'], + afterDown: ['false', 'true', 'false'], + activeDescendant: 'chat-feedback-survey-option-instance-1-routing-1', + }); + }); + + test('keeps Space from reaching the chat list, which would toggle the row', () => { + const { container } = createWidget(); + const list = container.querySelector('.chat-feedback-survey-list')!; + + let reachedAncestor = false; + store.add(dom.addDisposableListener(container.parentElement!, dom.EventType.KEY_DOWN, () => { reachedAncestor = true; })); + + pressKey(list, BrowserKey.Space, ' '); + + assert.strictEqual(reachedAncestor, false); + }); + + test('hands focus back when the panel closes', () => { + const harness = createWidget(); + + harness.state.current = { ...harness.state.current, status: ChatModelFeedbackSurveyStatus.Collapsed }; + harness.rerender(); + + assert.deepStrictEqual({ + focusRestores: harness.focusRestores, + panels: harness.container.querySelectorAll('.chat-feedback-survey-container').length, + }, { + focusRestores: 1, + panels: 0, + }); + }); + + test('shows an acknowledgement instead of questions once answered', () => { + const { container } = createWidget({ isSubmitted: true }); + + assert.deepStrictEqual({ + options: container.querySelectorAll('.chat-feedback-survey-list-item').length, + hasCard: container.querySelectorAll('.chat-feedback-survey-container').length, + }, { + options: 0, + hasCard: 1, + }); + }); + + test('escape dismisses the survey', () => { + const harness = createWidget(); + const list = harness.container.querySelector('.chat-feedback-survey-list')!; + + pressKey(list, BrowserKey.Escape, 'Escape'); + + assert.strictEqual(harness.dismissals, 1); + }); + + test('renders one panel when the survey opens itself while the row is rendering', () => { + // Reading the survey can open it, which reports a change back while render is running. + const changeEmitter = store.add(new Emitter()); + let fired = false; + const harness = createWidget({ + openTrigger: 'chance', + onDidChangeSurveyState: changeEmitter.event, + onGetSurvey: () => { + if (!fired) { + fired = true; + changeEmitter.fire({ sessionResource: response.sessionResource, requestId: response.requestId }); + } + }, + }); + + assert.strictEqual(harness.container.querySelectorAll('.chat-feedback-survey-container').length, 1); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/mockChatModelFeedbackSurveyService.ts b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/mockChatModelFeedbackSurveyService.ts new file mode 100644 index 0000000000000..0b9609201fef4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/mockChatModelFeedbackSurveyService.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IChatModelFeedbackSurveyService, IChatModelFeedbackSurveyState } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; + +/** Never offers a survey, matching any build without the experiment configured. */ +export class MockChatModelFeedbackSurveyService implements IChatModelFeedbackSurveyService { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeSurveyState = Event.None; + readonly onDidChangeConfiguration = Event.None; + + getSurvey(_response: IChatResponseViewModel): IChatModelFeedbackSurveyState | undefined { + return undefined; + } + + toggle(_response: IChatResponseViewModel): void { } + notifyModelSwitchedAway(_sessionResource: URI, _fromModelId: string, _toModelId: string): void { } + answerChoice(_response: IChatResponseViewModel, _stepId: string, _optionId: string): void { } + submit(_response: IChatResponseViewModel, _comment?: string): void { } + dismiss(_response: IChatResponseViewModel, _comment?: string): void { } + setCommentDraft(_response: IChatResponseViewModel, _comment: string): void { } +} 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 7ac24846a99e9..0537d740c34f9 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 @@ -39,6 +39,8 @@ import { ChatEditorOptions } from '../../../browser/widget/chatOptions.js'; import { shouldRenderGeneratedImageResult, shouldRenderSessionCreatedResult } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; import { getGeneratedImageResultParts, getGeneratedImageResultPartsFromContent } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatGeneratedImageResultSubPart.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; +import { IChatModelFeedbackSurveyService } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { MockChatModelFeedbackSurveyService } from '../feedbackSurvey/mockChatModelFeedbackSurveyService.js'; suite('ChatListRenderer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -627,6 +629,7 @@ suite('ChatListRenderer', () => { configurationService.setUserConfiguration(ChatConfiguration.TurnStatusPills, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); @@ -694,6 +697,7 @@ suite('ChatListRenderer', () => { configurationService.setUserConfiguration(ChatConfiguration.TurnStatusPills, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); @@ -1072,6 +1076,7 @@ suite('ChatListRenderer', () => { configurationService.setUserConfiguration('workbench.reduceMotion', 'on'); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); @@ -1167,6 +1172,7 @@ suite('ChatListRenderer', () => { configurationService.setUserConfiguration(ChatConfiguration.Verbose, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); instantiationService.stub(IViewDescriptorService, { onDidChangeLocation: Event.None, @@ -1261,6 +1267,7 @@ suite('ChatListRenderer', () => { configurationService.setUserConfiguration(ChatConfiguration.Verbose, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); @@ -1370,6 +1377,7 @@ suite('ChatListRenderer', () => { configurationService.setUserConfiguration(ChatConfiguration.Verbose, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); @@ -1452,6 +1460,7 @@ suite('ChatListRenderer', () => { configurationService.setUserConfiguration(ChatConfiguration.TurnStatusPills, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); @@ -1551,6 +1560,7 @@ suite('ChatListRenderer', () => { const configurationService = new TestConfigurationService(); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index 079d86f1ec227..12db45d6c45f3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -26,6 +26,8 @@ import { ChatAgentService, IChatAgentService } from '../../../common/participant import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; +import { IChatModelFeedbackSurveyService } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { MockChatModelFeedbackSurveyService } from '../feedbackSurvey/mockChatModelFeedbackSurveyService.js'; function nextFrame(): Promise { return new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); @@ -64,6 +66,7 @@ suite('ChatListWidget', () => { configurationService.setUserConfiguration(ChatConfiguration.Verbose, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); instantiationService.stub(IAccessibleViewService, { getOpenAriaHint: () => '' }); instantiationService.stub(IChatAccessibilityService, { diff --git a/src/vs/workbench/contrib/chat/test/common/feedbackSurvey/chatModelFeedbackSurveyConfig.test.ts b/src/vs/workbench/contrib/chat/test/common/feedbackSurvey/chatModelFeedbackSurveyConfig.test.ts new file mode 100644 index 0000000000000..fed3f0ddc4ecc --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/feedbackSurvey/chatModelFeedbackSurveyConfig.test.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION, IChatModelFeedbackSurveyMatchContext, matchesChatModelFeedbackSurvey, parseChatModelFeedbackSurveyConfig } from '../../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; + +suite('ChatModelFeedbackSurveyConfig', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const validPayload = { + version: CHAT_MODEL_FEEDBACK_SURVEY_CONFIG_VERSION, + id: 'auto-routing-2026-08', + match: { selectedModels: ['auto'], harnesses: ['copilotcli', 'none'] }, + prompt: { cooldownDays: 7, maxPerSession: 1, chance: { initial: 0.1, increment: 0.05, max: 0.5 } }, + steps: [ + { + kind: 'choice', id: 'routing', title: 'Did Auto choose the right model for the job?', + options: [ + { id: 'yes', label: 'Yes' }, + { id: 'too-heavy', label: 'No - too heavy' }, + { id: 'too-light', label: 'No - too light' }, + ], + }, + { kind: 'text', id: 'comments', title: 'Anything else?', placeholder: 'Optional feedback', maxLength: 500 }, + ], + }; + + function parse(payload: unknown): ReturnType { + return parseChatModelFeedbackSurveyConfig(JSON.stringify(payload)); + } + + test('accepts a well formed payload and normalizes selectors', () => { + const result = parse({ ...validPayload, match: { selectedModels: [' AUTO '], modes: ['Agent'] } }); + + assert.deepStrictEqual(result.config, { + version: 1, + id: 'auto-routing-2026-08', + match: { selectedModels: ['auto'], resolvedModels: [], modes: ['agent'], harnesses: [], sessionTypes: [] }, + prompt: { + cooldownDays: 7, + maxPerSession: 1, + chance: { initial: 0.1, increment: 0.05, max: 0.5 }, + triggers: { modelSwitchedAway: { enabled: false, bypassCooldown: false } }, + }, + steps: [ + { + kind: 'choice', id: 'routing', title: 'Did Auto choose the right model for the job?', + options: [ + { id: 'yes', label: 'Yes' }, + { id: 'too-heavy', label: 'No - too heavy' }, + { id: 'too-light', label: 'No - too light' }, + ], + }, + { kind: 'text', id: 'comments', title: 'Anything else?', placeholder: 'Optional feedback', maxLength: 500 }, + ], + }); + }); + + test('rejects malformed payloads whole rather than partially', () => { + const errors = { + empty: parseChatModelFeedbackSurveyConfig('').error, + notJson: parseChatModelFeedbackSurveyConfig('{nope').error?.startsWith('payload is not valid JSON'), + wrongVersion: parse({ ...validPayload, version: 99 }).error, + badId: parse({ ...validPayload, id: 'Has Spaces' }).error, + unnarrowedMatch: parse({ ...validPayload, match: {} }).error, + noSteps: parse({ ...validPayload, steps: [] }).error, + duplicateStepId: parse({ ...validPayload, steps: [validPayload.steps[0], validPayload.steps[0]] }).error, + unknownKind: parse({ ...validPayload, steps: [{ kind: 'slider', id: 'a', title: 'T' }] }).error, + tooFewOptions: parse({ ...validPayload, steps: [{ kind: 'choice', id: 'a', title: 'T', options: [{ id: 'x', label: 'X' }] }] }).error, + badPromptLimit: parse({ ...validPayload, prompt: { maxPerSession: 0 } }).error, + badProbability: parse({ ...validPayload, prompt: { chance: { initial: 2 } } }).error, + invertedChance: parse({ ...validPayload, prompt: { chance: { initial: 0.5, max: 0.1 } } }).error, + badCooldown: parse({ ...validPayload, prompt: { cooldownDays: -1 } }).error, + badTrigger: parse({ ...validPayload, prompt: { triggers: { modelSwitchedAway: 'yes' } } }).error, + }; + + assert.deepStrictEqual(errors, { + empty: 'empty payload', + notJson: true, + wrongVersion: 'unsupported version 99, expected 1', + badId: 'missing or malformed survey id', + unnarrowedMatch: 'match must narrow at least one dimension', + noSteps: 'steps must be a non-empty array', + duplicateStepId: 'steps[1].id "routing" is duplicated', + unknownKind: 'steps[0].kind must be "choice" or "text"', + tooFewOptions: 'steps[0].options must have between 2 and 8 entries', + badPromptLimit: 'prompt.maxPerSession must be a positive integer', + badProbability: 'prompt.chance.initial must be a probability between 0 and 1', + invertedChance: 'prompt.chance.max must be greater than or equal to prompt.chance.initial', + badCooldown: 'prompt.cooldownDays must be a non-negative number', + badTrigger: 'prompt.triggers.modelSwitchedAway must be a boolean or an object', + }); + }); + + test('clamps a text step maxLength to the transport budget', () => { + const result = parse({ ...validPayload, steps: [{ kind: 'text', id: 'c', title: 'T', maxLength: 99999 }] }); + + assert.deepStrictEqual(result.config?.steps, [{ kind: 'text', id: 'c', title: 'T', placeholder: undefined, maxLength: 1000 }]); + }); + + test('rejects text step arrangements that would strand later steps', () => { + const choice = validPayload.steps[0]; + const text = validPayload.steps[1]; + const secondText = { kind: 'text', id: 'more', title: 'More?', maxLength: 100 }; + + assert.deepStrictEqual({ + twoTextSteps: parse({ ...validPayload, steps: [text, secondText] }).error, + textNotLast: parse({ ...validPayload, steps: [text, choice] }).error, + choiceOnly: parse({ ...validPayload, steps: [choice] }).error, + }, { + twoTextSteps: 'steps may contain at most one text step', + textNotLast: 'a text step must be the last step', + choiceOnly: undefined, + }); + }); + + test('defaults an omitted prompt block to manual-only surfacing', () => { + const result = parse({ ...validPayload, prompt: undefined }); + + assert.deepStrictEqual(result.config?.prompt, { + cooldownDays: 7, + maxPerSession: 1, + // Zero probability means the survey never opens unasked, but the control still shows. + chance: { initial: 0, increment: 0, max: 1 }, + triggers: { modelSwitchedAway: { enabled: false, bypassCooldown: false } }, + }); + }); + + suite('matching', () => { + + function match(context: IChatModelFeedbackSurveyMatchContext, payload: unknown = validPayload): boolean { + const config = parse(payload).config; + assert.ok(config, 'expected a valid config'); + return matchesChatModelFeedbackSurvey(config, context); + } + + test('matches a short selector against identifiers from every harness', () => { + // Local model ids are `/` and agent host ids are `:`. + assert.deepStrictEqual({ + bare: match({ selectedModelId: 'auto', harness: undefined }), + vendorQualified: match({ selectedModelId: 'copilot/auto', harness: undefined }), + agentHostQualified: match({ selectedModelId: 'agent-host-copilotcli:auto', harness: 'copilotcli' }), + byAlias: match({ selectedModelId: 'copilot/gpt-5.2', selectedModelAliases: ['auto'], harness: undefined }), + unrelatedModel: match({ selectedModelId: 'copilot/gpt-5.2', harness: undefined }), + }, { + bare: true, + vendorQualified: true, + agentHostQualified: true, + byAlias: true, + unrelatedModel: false, + }); + }); + + test('keeps selected and resolved models as independent dimensions', () => { + const payload = { ...validPayload, match: { selectedModels: ['auto'], resolvedModels: ['gpt-5.2'] } }; + + assert.deepStrictEqual({ + both: match({ selectedModelId: 'copilot/auto', resolvedModelId: 'gpt-5.2' }, payload), + selectedOnly: match({ selectedModelId: 'copilot/auto', resolvedModelId: 'claude-sonnet-4.5' }, payload), + resolvedOnly: match({ selectedModelId: 'copilot/gpt-5.2', resolvedModelId: 'gpt-5.2' }, payload), + }, { + both: true, + selectedOnly: false, + resolvedOnly: false, + }); + }); + + test('treats a session with no agent host as the "none" harness', () => { + assert.deepStrictEqual({ + noHarnessAllowed: match({ selectedModelId: 'auto', harness: undefined }), + harnessAllowed: match({ selectedModelId: 'auto', harness: 'copilotcli' }), + harnessExcluded: match({ selectedModelId: 'auto', harness: 'claude' }), + }, { + noHarnessAllowed: true, + harnessAllowed: true, + harnessExcluded: false, + }); + }); + }); +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index 798a009ff7dfe..c67ff4ba93413 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -14,6 +14,8 @@ import { IMenu, IMenuItem, IMenuService, MenuId, MenuItemAction } from '../../.. import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IChatModelFeedbackSurveyService } from '../../../../contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; +import { MockChatModelFeedbackSurveyService } from '../../../../contrib/chat/test/browser/feedbackSurvey/mockChatModelFeedbackSurveyService.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { ILinkPresentationService } from '../../../../../platform/dataChannel/common/dataChannel.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -139,6 +141,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.define(IMenuService, FixtureMenuService); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.define(IListService, ListService); + reg.defineInstance(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); reg.defineInstance(ILinkPresentationService, new class extends mock() { override getLinkPresentationRule() { return undefined; } override createLinkPresentationWatcher() { return undefined; } From 342a75317e6080debfa0c828a0c1b7441f184670 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:06:55 -0700 Subject: [PATCH 17/29] chore: add more foundry-local-sdk patches (#331843) * chore: add more foundry-local-sdk patches * Bump cachesalt * Add back pip auth for Darwin * chore: add param for restored case * Fix flaky McpStdioStateHandler 'sigkill after grace' test Co-authored-by: rzhao271 <7199958+rzhao271@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- build/.cachesalt | 2 +- .../alpine/product-build-alpine-node-modules.yml | 10 ++++++++++ build/azure-pipelines/common/foundry-local.yml | 9 +++++++++ .../darwin/product-build-darwin-node-modules.yml | 10 ++++++++++ .../linux/product-build-linux-node-modules.yml | 10 ++++++++++ .../web/product-build-web-node-modules.yml | 10 ++++++++++ .../win32/product-build-win32-node-modules.yml | 10 ++++++++++ .../contrib/mcp/test/node/mcpStdioStateHandler.test.ts | 8 ++++++-- 8 files changed, 66 insertions(+), 3 deletions(-) diff --git a/build/.cachesalt b/build/.cachesalt index f5c2b5725ddbc..bd7579fd483e0 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2026-08-05T23:47:14.698Z \ No newline at end of file +2026-08-20T17:32:07.583Z \ No newline at end of file diff --git a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml index 2f1423a7d1f82..a7cc0ab6f3d20 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml @@ -61,6 +61,11 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + onlyOnNodeModulesCacheMiss: true + - task: Docker@1 inputs: azureSubscriptionEndpoint: vscode @@ -128,6 +133,11 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ../common/foundry-local.yml@self + parameters: + phase: install + onlyOnNodeModulesCacheMiss: true + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/common/foundry-local.yml b/build/azure-pipelines/common/foundry-local.yml index d40b360b8f33d..fd9d88e890754 100644 --- a/build/azure-pipelines/common/foundry-local.yml +++ b/build/azure-pipelines/common/foundry-local.yml @@ -4,15 +4,24 @@ parameters: values: - prepare - install + - name: onlyOnNodeModulesCacheMiss + type: boolean + default: false steps: - ${{ if eq(parameters.phase, 'prepare') }}: - task: NuGetAuthenticate@1 + ${{ if eq(parameters.onlyOnNodeModulesCacheMiss, true) }}: + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NuGet Authentication - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts + ${{ if eq(parameters.onlyOnNodeModulesCacheMiss, true) }}: + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Disable Foundry Local Native Install - ${{ if eq(parameters.phase, 'install') }}: - script: node build/azure-pipelines/common/foundryLocalInstall.ts + ${{ if eq(parameters.onlyOnNodeModulesCacheMiss, true) }}: + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Install Foundry Local Native Dependencies diff --git a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml index b4cadd1ec8418..3317e43816e99 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml @@ -75,6 +75,11 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Setup PyPI Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + onlyOnNodeModulesCacheMiss: true + - script: | set -e c++ --version @@ -102,6 +107,11 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ../common/foundry-local.yml@self + parameters: + phase: install + onlyOnNodeModulesCacheMiss: true + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml index dbcd21676665d..ea09134e1a863 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -82,6 +82,11 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + onlyOnNodeModulesCacheMiss: true + - script: | set -e @@ -142,6 +147,11 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ../common/foundry-local.yml@self + parameters: + phase: install + onlyOnNodeModulesCacheMiss: true + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/web/product-build-web-node-modules.yml b/build/azure-pipelines/web/product-build-web-node-modules.yml index cc61a7a015a63..4f935de733648 100644 --- a/build/azure-pipelines/web/product-build-web-node-modules.yml +++ b/build/azure-pipelines/web/product-build-web-node-modules.yml @@ -54,6 +54,11 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + onlyOnNodeModulesCacheMiss: true + - script: | set -e ./build/azure-pipelines/linux/apt-retry.sh sudo apt-get update @@ -79,6 +84,11 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ../common/foundry-local.yml@self + parameters: + phase: install + onlyOnNodeModulesCacheMiss: true + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/win32/product-build-win32-node-modules.yml b/build/azure-pipelines/win32/product-build-win32-node-modules.yml index eed6ebdd19925..528a580afe3e4 100644 --- a/build/azure-pipelines/win32/product-build-win32-node-modules.yml +++ b/build/azure-pipelines/win32/product-build-win32-node-modules.yml @@ -71,6 +71,11 @@ jobs: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + onlyOnNodeModulesCacheMiss: true + - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" @@ -85,6 +90,11 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ../common/foundry-local.yml@self + parameters: + phase: install + onlyOnNodeModulesCacheMiss: true + - powershell: node build/azure-pipelines/common/checkNativeOptionalDeps.ts condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Verify native optional dependency binaries diff --git a/src/vs/workbench/contrib/mcp/test/node/mcpStdioStateHandler.test.ts b/src/vs/workbench/contrib/mcp/test/node/mcpStdioStateHandler.test.ts index 0d25f80008da8..36d3cec8cfa10 100644 --- a/src/vs/workbench/contrib/mcp/test/node/mcpStdioStateHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/node/mcpStdioStateHandler.test.ts @@ -9,7 +9,9 @@ import * as assert from 'assert'; import { McpStdioStateHandler } from '../../node/mcpStdioStateHandler.js'; import { isWindows } from '../../../../../base/common/platform.js'; -const GRACE_TIME = 100; +// Must be comfortably larger than the time it takes to spawn the helper shell +// script that signals the process tree, otherwise SIGKILL can race SIGTERM. +const GRACE_TIME = 1000; suite('McpStdioStateHandler', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -75,7 +77,9 @@ suite('McpStdioStateHandler', () => { }); } - test('sigkill after grace', async () => { + test('sigkill after grace', async function () { + this.timeout(GRACE_TIME * 10); + const { handler, output } = run(` setInterval(() => {}, 1000); process.stdin.on('end', () => process.stdout.write('stdin ended\\n')); From 0b862a7b4d5324ab8d5e8e50f19e1012ad375662 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 21 Aug 2026 06:09:46 +1000 Subject: [PATCH 18/29] agentHost: stop the agent editing host-created attachment snapshots (#331154) (#331840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * agentHost: stop the agent editing host-created attachment snapshots (#331154) When a user-message attachment is not a plain, already-existing file:// file (pasted content, an unsaved editor, or a read-only git: diff), the Agent Host snapshots the bytes to .../agentSessionData//attachments/... and handed the copy to the model as an ordinary editable file. The model then edited the throwaway copy instead of returning the transformed content / editing the real file. This is the uncovered sibling of #319314 (fix #319452 only exempted existing file:// attachments). Keep each snapshot visible (path preserved so the model can read it) but signal it read-only so the agent does not edit it: - Tag every host-created snapshot with a _meta marker (new agentSnapshotAttachmentMeta) at the snapshot-write point and when a snapshot copy is re-attached from the attachments folder. - Copilot: send the snapshot as {type:'file', path} with a plain display name and deliver a read-only note via the additionalContext channel (rendered as a ) on the main turn; steering does not fire the user-prompt-submitted hook, so its note is appended to the steering prompt as a block (stripped from the bubble, forwarded to the model). - Codex: annotate the existing @path mention with "(read-only snapshot - do not edit)". - Claude: annotate the existing path line likewise. - Write-deny backstop: when a provider raises an interactive confirmation for a write under the session attachments dir, the host hard-denies it before auto-approval. Known limitation: the write-deny only fires on an interactive permission prompt, so in autopilot/bypass modes the read-only signal is advisory only. A mode-independent guarantee (marking snapshots read-only on disk) is tracked as a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: address PR review feedback for snapshot read-only handling (#331154) - agentService: match the session attachments dir by URI containment (extUriBiasedIgnorePathCase.isEqualOrParent) instead of a string prefix, so a sibling like .../attachments-backup is not mis-tagged and a case-differing snapshot path is still recognised. Applied to both _isRewritableAttachment and _isUntaggedSnapshotResource via a shared _isUnderAttachmentsRoot helper. - copilot: keep a snapshotted selection on the selection path (preserving the selected text and range) instead of collapsing it to a whole-file attachment; the read-only signal already rides the additionalContext/ note. Removed the now-redundant snapshot branch in _toSdkAttachment. - agentSnapshotAttachmentMeta: correct the doc — Copilot sends the file path and conveys read-only out-of-band, it does not omit the path. - tests: add an AgentSideEffects pending-confirmation test that a write under the session attachments dir is hard-denied even with global auto-approve, with no confirmation-ready action dispatched; update the Copilot snapshot-selection test to expect the selection path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meta/agentSnapshotAttachmentMeta.ts | 53 ++++++ .../platform/agentHost/node/agentService.ts | 86 +++++++--- .../agentHost/node/agentSideEffects.ts | 17 +- .../node/claude/claudePromptResolver.ts | 10 +- .../node/codex/codexPromptResolver.ts | 15 +- .../node/copilot/copilotAgentSession.ts | 57 ++++++- .../agentHost/node/sessionPermissions.ts | 20 +++ .../agentHost/test/node/agentService.test.ts | 11 +- .../test/node/agentSideEffects.test.ts | 64 +++++++ .../node/claude/claudePromptResolver.test.ts | 45 +++++ .../node/codex/codexPromptResolver.test.ts | 22 +++ .../test/node/copilotAgentSession.test.ts | 159 ++++++++++++++++++ .../test/node/sessionPermissions.test.ts | 29 ++++ 13 files changed, 556 insertions(+), 32 deletions(-) create mode 100644 src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts create mode 100644 src/vs/platform/agentHost/test/node/claude/claudePromptResolver.test.ts diff --git a/src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts b/src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts new file mode 100644 index 0000000000000..7d8e62d1782ee --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isString } from '../../../../base/common/types.js'; +import { MessageAttachmentKind, type MessageAttachment } from '../state/protocol/state.js'; + +/** + * Namespaced `_meta` slot marking a {@link MessageAttachmentKind.Resource} attachment as a + * host-created snapshot: an on-disk copy the agent host wrote under the session attachments + * directory to carry client-resident or derived content (pasted text/images, unsaved editors, + * read-only `git:` diff views) that cannot be referenced as a real workspace file. + * + * Such snapshots are **read-only context** — the model should consume their content, not edit + * the copy. Providers use {@link isHostSnapshotAttachment} / {@link readHostSnapshotAttachmentMeta} + * to signal read-only: Copilot still sends the file path (so the model can read it on demand) but + * conveys the read-only intent out-of-band on the prompt (an `additionalContext` / `` + * note), while Codex/Claude annotate the path reference inline as read-only. The `contentType` is + * preserved because the on-disk `Resource` no longer carries the original MIME type. + */ +export const HostSnapshotAttachmentMetadataKey = 'vscode.agentHost.snapshotAttachment'; + +export interface IHostSnapshotAttachmentMetadata { + /** Always `true`; marks the attachment as a host-created read-only snapshot. */ + readonly isSnapshot: true; + /** The original content MIME type, preserved so consumers can inline without re-sniffing. */ + readonly contentType?: string; +} + +export function toHostSnapshotAttachmentMeta(contentType: string | undefined): Record { + return { + [HostSnapshotAttachmentMetadataKey]: contentType ? { isSnapshot: true, contentType } : { isSnapshot: true } + }; +} + +export function readHostSnapshotAttachmentMeta(attachment: { readonly _meta?: Record }): IHostSnapshotAttachmentMetadata | undefined { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced snapshot attachment slot; validated below. + const metadata = attachment._meta?.[HostSnapshotAttachmentMetadataKey]; + if (!isRecord(metadata) || metadata.isSnapshot !== true) { + return undefined; + } + const contentType = isString(metadata.contentType) ? metadata.contentType : undefined; + return contentType ? { isSnapshot: true, contentType } : { isSnapshot: true }; +} + +export function isHostSnapshotAttachment(attachment: MessageAttachment): boolean { + return attachment.type === MessageAttachmentKind.Resource && readHostSnapshotAttachmentMeta(attachment) !== undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 984c6724e05a6..9c2cb2258d810 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -39,6 +39,7 @@ import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKin import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; +import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; import { IProductService } from '../../product/common/productService.js'; @@ -4537,10 +4538,11 @@ export class AgentService extends Disposable implements IAgentService { if (action.type !== ActionType.ChatTurnStarted && action.type !== ActionType.ChatPendingMessageSet) { return false; } - const attachmentsRootStr = this._attachmentsRoot(sessionURI).toString(); - return !!action.message.attachments?.some(a => this._isRewritableAttachment(a, attachmentsRootStr)); + const attachmentsRoot = this._attachmentsRoot(sessionURI); + return !!action.message.attachments?.some(a => + this._isRewritableAttachment(a, attachmentsRoot) || this._isUntaggedSnapshotResource(a, attachmentsRoot)); } - private _isRewritableAttachment(attachment: MessageAttachment, attachmentsRootStr: string): boolean { + private _isRewritableAttachment(attachment: MessageAttachment, attachmentsRoot: URI): boolean { if (attachment.type === MessageAttachmentKind.EmbeddedResource) { return true; } @@ -4550,7 +4552,7 @@ export class AgentService extends Disposable implements IAgentService { if (attachment.displayKind === 'directory') { return false; } - if (attachment.uri.startsWith(attachmentsRootStr)) { + if (this._isUnderAttachmentsRoot(attachment.uri, attachmentsRoot)) { return false; } return true; @@ -4558,6 +4560,30 @@ export class AgentService extends Disposable implements IAgentService { return false; } + /** + * A {@link MessageAttachmentKind.Resource} that already points inside our session attachments + * folder but is not yet tagged as a host snapshot. This happens when a previously snapshotted + * copy is re-attached (e.g. the user opens the copy, or implicit context captures it). It must + * not be re-snapshotted, but it must still be tagged so downstream providers treat it as + * read-only rather than an editable file (#331154). + */ + private _isUntaggedSnapshotResource(attachment: MessageAttachment, attachmentsRoot: URI): boolean { + return attachment.type === MessageAttachmentKind.Resource + && attachment.displayKind !== 'directory' + && this._isUnderAttachmentsRoot(attachment.uri, attachmentsRoot) + && !isHostSnapshotAttachment(attachment); + } + + /** + * Whether an attachment URI points at the session attachments directory or a descendant. Uses URI + * containment (not a string-prefix check) so a sibling such as `.../attachments-backup/file` is not + * matched, and — on case-insensitive filesystems — a real snapshot whose path casing differs is + * still recognised. Mirrors the write-deny classifier (`isSessionAttachmentPath`). + */ + private _isUnderAttachmentsRoot(attachmentUri: string, attachmentsRoot: URI): boolean { + return extUriBiasedIgnorePathCase.isEqualOrParent(URI.parse(attachmentUri), attachmentsRoot); + } + private _attachmentsRoot(sessionURI: string): URI { return joinPath(this._sessionDataService.getSessionDataDir(URI.parse(sessionURI)), SESSION_ATTACHMENTS_DIRNAME); } @@ -4581,32 +4607,40 @@ export class AgentService extends Disposable implements IAgentService { return action; } const attachmentsRoot = this._attachmentsRoot(channel); - const attachmentsRootStr = attachmentsRoot.toString(); - const rewritten = await Promise.all(attachments.map(a => this._rewriteSingleAttachment(a, attachmentsRoot, attachmentsRootStr, clientId))); + const rewritten = await Promise.all(attachments.map(a => this._rewriteSingleAttachment(a, attachmentsRoot, clientId))); return { ...action, message: { ...action.message, attachments: rewritten }, }; } - private async _rewriteSingleAttachment(attachment: MessageAttachment, attachmentsRoot: URI, attachmentsRootStr: string, clientId: string): Promise { + private async _rewriteSingleAttachment(attachment: MessageAttachment, attachmentsRoot: URI, clientId: string): Promise { try { if (attachment.type === MessageAttachmentKind.EmbeddedResource) { const bytes = decodeBase64(attachment.data).buffer; const basename = this._attachmentBasename(attachment.label, attachment.contentType); - return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot); - } - if (attachment.type === MessageAttachmentKind.Resource && this._isRewritableAttachment(attachment, attachmentsRootStr)) { - const originalUri = URI.parse(attachment.uri); - // If the attachment references a file that already exists on the agent - // host side, leave it untouched rather than snapshotting a client copy (#319314). - if (originalUri.scheme === Schemas.file && await this._fileExistsSafe(originalUri)) { - return attachment; + return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot, attachment.contentType); + } + if (attachment.type === MessageAttachmentKind.Resource) { + // A snapshot re-attached from our own attachments folder (e.g. the user opened the + // copy, or implicit context captured it) must still be tagged read-only so providers + // don't treat it as an editable file (#331154), but must not be re-snapshotted. + if (this._isUntaggedSnapshotResource(attachment, attachmentsRoot)) { + return this._tagSnapshotAttachment(attachment, getMediaMime(URI.parse(attachment.uri).path)); } + if (this._isRewritableAttachment(attachment, attachmentsRoot)) { + const originalUri = URI.parse(attachment.uri); + // If the attachment references a file that already exists on the agent + // host side, leave it untouched rather than snapshotting a client copy (#319314). + if (originalUri.scheme === Schemas.file && await this._fileExistsSafe(originalUri)) { + return attachment; + } - const bytes = await this._readClientResource(originalUri, clientId); - const basename = this._attachmentBasename(attachment.label, getMediaMime(originalUri.path)); - return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot); + const contentType = getMediaMime(originalUri.path); + const bytes = await this._readClientResource(originalUri, clientId); + const basename = this._attachmentBasename(attachment.label, contentType); + return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot, contentType); + } } } catch (err) { this._logService.warn(`[AgentService] Failed to rewrite attachment '${attachment.label}': ${toErrorMessage(err)}`); @@ -4614,6 +4648,17 @@ export class AgentService extends Disposable implements IAgentService { return attachment; } + /** + * Tag an existing {@link MessageResourceAttachment} as a host snapshot (read-only) without + * re-writing its bytes. Used for copies re-attached from the session attachments folder. + */ + private _tagSnapshotAttachment(attachment: MessageResourceAttachment, contentType: string | undefined): MessageResourceAttachment { + return { + ...attachment, + _meta: { ...attachment._meta, ...toHostSnapshotAttachmentMeta(contentType) }, + }; + } + /** * Like {@link IFileService.exists} but never throws (e.g. when no provider * is registered for the URI scheme), returning `false` in that case. @@ -4656,6 +4701,7 @@ export class AgentService extends Disposable implements IAgentService { bytes: Uint8Array, basename: string, attachmentsRoot: URI, + contentType: string | undefined, ): Promise { const id = generateUuid(); const target = joinPath(attachmentsRoot, id, basename); @@ -4666,7 +4712,9 @@ export class AgentService extends Disposable implements IAgentService { label: original.label, displayKind: original.displayKind, range: original.range, - _meta: original._meta, + // Tag the on-disk copy as a read-only host snapshot so downstream providers present it + // as content (not an editable file) and never let the model edit the copy (#331154). + _meta: { ...original._meta, ...toHostSnapshotAttachmentMeta(contentType) }, }; if (original.type === MessageAttachmentKind.Resource && original.selection) { rewritten.selection = original.selection; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5b6fa6bb4d09b..2f9928c9d6e50 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1520,7 +1520,12 @@ export class AgentSideEffects extends Disposable { requestSandboxBypass: e.requestSandboxBypass, shellLanguage: e.shellLanguage, }; - const autoApproval = e.managedApprovalRequired + // A write to a read-only host snapshot under the session attachments dir must be refused when + // we get an interactive confirmation for it (#331154), so decide this before (and instead of) + // auto-approval. Providers that auto-approve upstream never raise this signal, so this is not a + // universal guarantee — the read-only attachment presentation is the primary defense there. + const forbiddenSnapshotWrite = this._permissionManager.isForbiddenSnapshotWrite(approvalEvent, sessionKey); + const autoApproval = e.managedApprovalRequired || forbiddenSnapshotWrite ? undefined : await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); @@ -1538,6 +1543,16 @@ export class AgentSideEffects extends Disposable { const contributor = e.state.contributor ?? toolCall?.contributor; let effective = e; const toolCallKey = `${sessionKey}:${e.state.toolCallId}`; + if (forbiddenSnapshotWrite) { + // Hard-deny: the model tried to edit a read-only attachment snapshot. Refusing lets the + // model recover (e.g. reply with the transformed content / edit the real file) instead of + // silently mutating the throwaway copy. + this._logService.warn(`[AgentSideEffects] Denying write to read-only attachment snapshot: toolCallId=${e.state.toolCallId}`); + this._toolCallAgents.delete(toolCallKey); + this._managedApprovalToolCalls.delete(toolCallKey); + agent.respondToPermissionRequest(e.state.toolCallId, false); + return; + } if (e.managedApprovalRequired) { this._managedApprovalToolCalls.add(toolCallKey); } else { diff --git a/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts b/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts index b06b6b298213e..c19bcbb9d6e1d 100644 --- a/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts +++ b/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts @@ -6,6 +6,7 @@ import type Anthropic from '@anthropic-ai/sdk'; import { URI } from '../../../../base/common/uri.js'; import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; +import { isHostSnapshotAttachment } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; /** @@ -56,11 +57,16 @@ export function resolvePromptToContentBlocks( continue; } const uri = URI.parse(att.uri); + // A host-created snapshot (pasted content, unsaved editor, git: diff, …) is read-only context; + // annotate the path inline so the model doesn't edit the copy (#331154). This is an advisory + // signal: the host attachments-dir write-deny only fires for interactive permission prompts, so + // it does NOT cover Claude's acceptEdits/bypassPermissions/auto modes or subagent inner tools. + const readonlySuffix = isHostSnapshotAttachment(att) ? ' (read-only snapshot — do not edit this file)' : ''; if (att.displayKind === 'selection') { const startLine = att.selection ? `:${att.selection.range.start.line + 1}` : ''; - refLines.push(`- ${uriToString(uri)}${startLine}`); + refLines.push(`- ${uriToString(uri)}${startLine}${readonlySuffix}`); } else { - refLines.push(`- ${uriToString(uri)}`); + refLines.push(`- ${uriToString(uri)}${readonlySuffix}`); } } if (feedbackBlocks.length > 0) { diff --git a/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts b/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts index 1e8b5aa8b5902..0a2c66999382d 100644 --- a/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts +++ b/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts @@ -9,6 +9,7 @@ import * as os from 'os'; import { join } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { MessageAttachmentKind, type MessageAttachment, type MessageEmbeddedResourceAttachment } from '../../common/state/sessionState.js'; +import { isHostSnapshotAttachment } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import type { UserInput } from './protocol/generated/v2/UserInput.js'; import type { TextElement } from './protocol/generated/v2/TextElement.js'; @@ -61,14 +62,18 @@ export function resolveCodexInput( // absolute path as a `@` mention so the codex // prompt template can render / read it. const uri = URI.parse(att.uri); - if (uri.scheme === 'file') { - textChunks.push(`@${uri.fsPath}`); - } else { + const mention = uri.scheme === 'file' // Non-file URIs (vscode-userdata://, untitled://, …) // are surfaced as a plain string so they still show // up in the prompt, even if codex can't resolve them. - textChunks.push(uri.toString()); - } + ? `@${uri.fsPath}` + : uri.toString(); + // A host-created snapshot (pasted content, unsaved editor, git: diff, …) is + // read-only context. Annotate the path inline so the model doesn't edit the copy + // (#331154). This is an advisory signal: Codex tool calls don't route through the + // host permission layer, so the attachments-dir write-deny does not apply here + // (in `workspace-write` the snapshot is also outside the sandbox, which blocks it). + textChunks.push(isHostSnapshotAttachment(att) ? `${mention} (read-only snapshot — do not edit this file)` : mention); break; } case MessageAttachmentKind.EmbeddedResource: { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index bf80387d41135..f486cc80cdbaf 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -44,6 +44,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js'; import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; +import { isHostSnapshotAttachment } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; @@ -621,6 +622,7 @@ class CopilotTurn { */ export class CopilotAgentSession extends Disposable { private _hostInstructions: readonly string[] | undefined; + private _pendingSnapshotReminder: string | undefined; readonly sessionId: string; readonly resourceUri: URI; private readonly _ownerSessionUri: URI; @@ -2120,6 +2122,7 @@ export class CopilotAgentSession extends Disposable { } const turn = this._currentTurn; this._hostInstructions = hostInstructions; + this._pendingSnapshotReminder = this._snapshotReadonlyReminder(attachments); try { await this._send(prompt, attachments, mode); } catch (err) { @@ -2133,15 +2136,51 @@ export class CopilotAgentSession extends Disposable { this._clearActiveTurn(); } this._hostInstructions = undefined; + this._pendingSnapshotReminder = undefined; throw err; } } handleUserPromptSubmitted(): { readonly additionalContext: string } | undefined { - const additionalContext = this._hostInstructions?.join('\n\n'); + const parts = [ + ...(this._hostInstructions ?? []), + ...(this._pendingSnapshotReminder ? [this._pendingSnapshotReminder] : []), + ]; this._hostInstructions = undefined; + this._pendingSnapshotReminder = undefined; + const additionalContext = parts.length > 0 ? parts.join('\n\n') : undefined; return additionalContext ? { additionalContext } : undefined; } + + /** + * Build a read-only reminder naming each host-created snapshot attachment + * (pasted content, unsaved editor, git: diff, …) so the model treats the + * on-disk copy as read-only context and does not edit it (#331154). Returns + * `undefined` when no attachment is a snapshot. The read-only signal rides + * the prompt (as `additionalContext` on the main turn, a `` note + * on steering) rather than the attachment, because the runtime drops a file + * attachment's `displayName` for text snapshots. + */ + private _snapshotReadonlyReminder(attachments: readonly MessageAttachment[] | undefined): string | undefined { + if (!attachments?.length) { + return undefined; + } + const paths: string[] = []; + for (const attachment of attachments) { + if (attachment.type !== MessageAttachmentKind.Resource || !isHostSnapshotAttachment(attachment)) { + continue; + } + const uri = URI.parse(attachment.uri); + paths.push(uri.scheme === 'file' ? uri.fsPath : uri.toString()); + } + if (paths.length === 0) { + return undefined; + } + return 'The following attached files are read-only snapshots of content the user shared ' + + '(pasted text, an unsaved editor, or a diff view) and must not be edited:\n' + + paths.map(path => `- ${path}`).join('\n'); + } + private async _send(prompt: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined): Promise { this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); @@ -2453,6 +2492,12 @@ export class CopilotAgentSession extends Disposable { const uri = URI.parse(attachment.uri); const path = uri.scheme === 'file' ? uri.fsPath : uri.toString(); const displayName = attachment.label ?? path; + // A host-created snapshot (pasted content, unsaved editor, git: diff, …) is shaped like any other + // resource here (file or selection). Its read-only signal is carried separately on the prompt — via + // `additionalContext` on the main turn and a `` note on steering (see + // `_snapshotReadonlyReminder`) — because the runtime drops a file attachment's `displayName` for text + // snapshots, rendering only the path in `` (#331154). Selected snapshots therefore keep + // the selection path below so the model still receives the selected text and range. if (attachment.selection) { try { const text = await this._readSelectedText(uri, attachment.selection.range); @@ -2534,8 +2579,16 @@ export class CopilotAgentSession extends Disposable { await this._reconcileMcpServerEnablement(); this._pendingSteeringFlips.set(steeringMessage.id, steeringMessage); const sdkAttachments = await this._toSdkAttachments(steeringMessage.message.attachments); + // Steering is injected into the active turn and never fires the SDK's `user-prompt-submitted` + // hook, so the read-only snapshot signal can't ride `additionalContext` here. Fold it into the + // prompt as a `` block instead: the runtime forwards it to the model, and the host's + // `stripPromptScaffolding` removes it from the displayed message (#331154). + const snapshotReminder = this._snapshotReadonlyReminder(steeringMessage.message.attachments); + const steeringPrompt = snapshotReminder + ? `${steeringMessage.message.text}\n\n\n${snapshotReminder}\n` + : steeringMessage.message.text; await this._wrapper.session.send({ - prompt: steeringMessage.message.text, + prompt: steeringPrompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined, mode: 'immediate', }); diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 7709e6d1bbdd5..b0b49eeb4bd0d 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -325,6 +325,26 @@ export class SessionPermissionManager extends Disposable { return undefined; } + /** + * Whether a write targets a file under the session attachments directory. Those files are + * host-created **read-only snapshots** of client/derived content (pasted text/images, unsaved + * editors, `git:` diff views); the model must never edit the copy (#331154). + * + * {@link _handleToolReady} hard-denies such writes when a provider raises an interactive + * `pending_confirmation` (the auto-approve checks in {@link getAutoApproval} would otherwise + * approve first). Note this only fires for the interactive / managed-approval flow — providers + * that auto-approve upstream (Copilot SDK `'on'`, Claude bypass/acceptEdits, or Codex, which never + * routes through the host permission layer) don't reach it, so the read-only presentation is the + * primary defense there. + */ + isForbiddenSnapshotWrite(e: IToolApprovalEvent, sessionKey: ProtocolURI): boolean { + if (e.permissionKind !== 'write' || !e.permissionPath) { + return false; + } + const sessionUri = URI.parse(isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey); + return isSessionAttachmentPath(this._sessionDataService, sessionUri, e.permissionPath); + } + /** Whether adding a persistent terminal auto-approve rule can suppress future prompts for this shell event. */ isAutoApproveRuleResolvable(e: IToolApprovalEvent, sessionKey: ProtocolURI): boolean { if (e.permissionKind !== 'shell' || !e.toolInput || e.requestSandboxBypass || !e.shellLanguage) { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 55e878fee42d0..c5d1e6d771a6e 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -42,6 +42,7 @@ import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; +import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; @@ -2384,7 +2385,7 @@ suite('AgentService (node dispatcher)', () => { }, { label: 'Pasted text #1', displayKind: undefined, - metadata, + metadata: { ...metadata, ...toHostSnapshotAttachmentMeta('text/plain') }, isSessionAttachment: true, fileName: 'Pasted text #1.txt', contents: 'large pasted text', @@ -2433,6 +2434,8 @@ suite('AgentService (node dispatcher)', () => { assert.ok(rewritten.uri.startsWith(attachmentsRoot.toString() + '/')); assert.strictEqual(rewritten.label, 'source.txt'); assert.strictEqual(rewritten.displayKind, 'document'); + // Tagged read-only so downstream providers present it as content, not an editable file (#331154). + assert.ok(isHostSnapshotAttachment(rewritten), 'should be tagged as a read-only snapshot'); const snapshot = await fileService.readFile(URI.parse(rewritten.uri)); assert.strictEqual(snapshot.value.toString(), 'hello world'); @@ -2502,7 +2505,7 @@ suite('AgentService (node dispatcher)', () => { }]); }); - test('does not re-snapshot attachments that already point under the session attachments folder', async () => { + test('does not re-snapshot attachments already under the attachments folder, but tags them read-only (#331154)', async () => { const { svc, agent, session, attachmentsRoot } = await setup(); const existing = joinPath(attachmentsRoot, 'previous-id', 'note.txt'); await fileService.writeFile(existing, VSBuffer.fromString('already snapshotted')); @@ -2516,7 +2519,9 @@ suite('AgentService (node dispatcher)', () => { const a = agent.sendMessageCalls[0].attachments?.[0]; assert.ok(a && a.type === MessageAttachmentKind.Resource); - assert.strictEqual(a.uri, existing.toString(), 'second-pass rewrite should be a no-op'); + assert.strictEqual(a.uri, existing.toString(), 'second-pass rewrite should not move the file'); + // A re-attached snapshot must still be tagged so providers treat it as read-only content. + assert.ok(isHostSnapshotAttachment(a), 're-attached snapshot should be tagged read-only'); }); test('preserves the original attachment when the source cannot be read', async () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index f2cc47addcc4c..c90696623859e 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -6532,6 +6532,70 @@ suite('AgentSideEffects', () => { { requestId: 'inner-perm-1', approved: true }, ]); }); + + test('hard-denies a write to a snapshot under the session attachments dir, even with global auto-approve, and dispatches no confirmation-ready action (#331154)', async () => { + // `isSessionAttachmentPath` compares the write path (a `file:` URI) against the session + // attachments dir, so the session-data dir must resolve to a `file:` URI here. + const attachmentsSessionDataService: ISessionDataService = { + ...createNullSessionDataService(), + getSessionDataDir: () => URI.file('/session-data/session-1'), + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: attachmentsSessionDataService, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + onTurnComplete: () => { }, + }); + + setupSession(); + // Global auto-approve would otherwise approve the write; the snapshot deny must win because it + // is evaluated before `getAutoApproval`. + stateManager.dispatchServerAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: true }, + }); + startTurn('turn-1'); + disposables.add(localSideEffects.registerProgressListener(agent)); + + // A confirmation-ready action would only be dispatched by the auto-approval path; the deny path + // returns before dispatching one, so capturing them proves nothing was surfaced to the client. + const readyActions: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatToolCallReady) { + readyActions.push(envelope); + } + })); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-snapshot-write', toolName: 'edit', displayName: 'Edit', contributor: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-snapshot-write', toolName: 'edit', displayName: 'Edit', + invocationMessage: 'Edit file', toolInput: undefined, + confirmationTitle: 'Edit file', edits: undefined, + }, + permissionKind: 'write', + permissionPath: '/session-data/session-1/attachments/abc/Pasted text #1.txt', + }); + + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); + assert.deepStrictEqual({ + responses: agent.respondToPermissionCalls, + readyActionCount: readyActions.length, + }, { + responses: [{ requestId: 'tc-snapshot-write', approved: false }], + readyActionCount: 0, + }); + }); }); // ---- Forwarding into IAgentHostChangesetService ------------------------ diff --git a/src/vs/platform/agentHost/test/node/claude/claudePromptResolver.test.ts b/src/vs/platform/agentHost/test/node/claude/claudePromptResolver.test.ts new file mode 100644 index 0000000000000..b6a63a06347a5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/claude/claudePromptResolver.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { toHostSnapshotAttachmentMeta } from '../../../common/meta/agentSnapshotAttachmentMeta.js'; +import { MessageAttachmentKind, type MessageAttachment } from '../../../common/state/protocol/state.js'; +import { resolvePromptToContentBlocks } from '../../../node/claude/claudePromptResolver.js'; + +suite('claudePromptResolver', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function referencesText(prompt: string, attachments: MessageAttachment[]): string { + return resolvePromptToContentBlocks(prompt, attachments) + .map(block => (block as { text?: string }).text ?? '') + .join('\n'); + } + + test('a host-created snapshot path is annotated read-only, a normal file reference is not (#331154)', () => { + const snapshotUri = URI.file('/data/attachments/id/Pasted text #1.txt'); + const snapshot: MessageAttachment = { + type: MessageAttachmentKind.Resource, + label: 'Pasted text #1', + displayKind: 'document', + uri: snapshotUri.toString(), + _meta: toHostSnapshotAttachmentMeta('text/plain'), + } as MessageAttachment; + const fileUri = URI.file('/workspace/real.txt'); + const normal: MessageAttachment = { + type: MessageAttachmentKind.Resource, + label: 'real.txt', + displayKind: 'document', + uri: fileUri.toString(), + } as MessageAttachment; + + const text = referencesText('trim this', [snapshot, normal]); + + assert.ok(text.includes(`- ${snapshotUri.fsPath} (read-only snapshot — do not edit this file)`), `text: ${text}`); + assert.ok(text.includes(`- ${fileUri.fsPath}`) && !text.includes(`- ${fileUri.fsPath} (read-only`), `text: ${text}`); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts index 67a30e4a1b606..0b00c09151bad 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../../common/state/sessionState.js'; +import { toHostSnapshotAttachmentMeta } from '../../../common/meta/agentSnapshotAttachmentMeta.js'; import { resolveCodexInput } from '../../../node/codex/codexPromptResolver.js'; suite('codexPromptResolver', () => { @@ -36,6 +37,27 @@ suite('codexPromptResolver', () => { assert.ok(text.includes('look at this')); }); + test('a host-created snapshot mention is annotated read-only, a normal file is not (#331154)', () => { + const snapshotUri = URI.file('/data/attachments/id/Pasted text #1.txt'); + const snapshot: MessageAttachment = { + type: MessageAttachmentKind.Resource, + label: 'Pasted text #1', + uri: snapshotUri.toString(), + _meta: toHostSnapshotAttachmentMeta('text/plain'), + } as MessageAttachment; + const fileUri = URI.file('/workspace/real.txt'); + const normal: MessageAttachment = { + type: MessageAttachmentKind.Resource, + label: 'real.txt', + uri: fileUri.toString(), + } as MessageAttachment; + + const text = (resolveCodexInput('trim this', [snapshot, normal]).input[0] as { text: string }).text; + + assert.ok(text.includes(`@${snapshotUri.fsPath} (read-only snapshot — do not edit this file)`), `text: ${text}`); + assert.ok(text.includes(`@${fileUri.fsPath}`) && !text.includes(`@${fileUri.fsPath} (read-only`), `text: ${text}`); + }); + test('Simple attachment with modelRepresentation is appended', () => { const att: MessageAttachment = { type: MessageAttachmentKind.Simple, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b0b984a5d4d6e..387e76e2d5966 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -38,6 +38,7 @@ import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction, type StateAction } from '../../common/state/sessionActions.js'; import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { TerminalClaimKind } from '../../common/state/protocol/state.js'; +import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization, type McpServerCustomization } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; @@ -978,6 +979,17 @@ async function createAgentSession(disposables: DisposableStore, options?: { // ---- Tests ------------------------------------------------------------------ +/** + * The exact read-only reminder the Copilot session builds for host-created + * snapshot attachments (mirrors `_snapshotReadonlyReminder`). Used to assert the + * main-turn `additionalContext` and the steering `` note. + */ +function expectedSnapshotReadonlyNote(paths: string[]): string { + return 'The following attached files are read-only snapshots of content the user shared ' + + '(pasted text, an unsaved editor, or a diff view) and must not be edited:\n' + + paths.map(path => `- ${path}`).join('\n'); +} + suite('CopilotAgentSession', () => { const disposables = new DisposableStore(); @@ -1472,6 +1484,120 @@ suite('CopilotAgentSession', () => { }]); }); + test('sends a host-created text snapshot as a read-only file reference with a read-only additionalContext note (#331154)', async () => { + const snapshotUri = URI.file('/data/attachments/id/Pasted text #1.txt'); + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('trim this', [{ + type: MessageAttachmentKind.Resource, + label: 'Pasted text #1', + displayKind: 'document', + uri: snapshotUri.toString(), + _meta: toHostSnapshotAttachmentMeta('text/plain'), + }]); + + // The snapshot is sent as an ordinary file (path preserved, plain display name) so the model can + // read it on demand; the read-only signal rides the user-prompt-submitted additionalContext + // (the runtime renders it as a ), and the message text is left unchanged. + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + additionalContext: session.handleUserPromptSubmitted(), + }, { + sendRequests: [{ + prompt: 'trim this', + attachments: [{ type: 'file', path: snapshotUri.fsPath, displayName: 'Pasted text #1' }], + }], + additionalContext: { additionalContext: expectedSnapshotReadonlyNote([snapshotUri.fsPath]) }, + }); + }); + + test('sends a snapshotted selection through the selection path so the model keeps the selected text, with a read-only note (#331154)', async () => { + const snapshotUri = URI.file('/data/attachments/id/snap.txt'); + const { session, mockSession } = await createAgentSession(disposables, { + fileContents: { + [snapshotUri.toString()]: 'line0\nhello world\nline2', + }, + }); + + await session.send('what is here?', [{ + type: MessageAttachmentKind.Resource, + label: 'snap.txt', + displayKind: 'selection', + uri: snapshotUri.toString(), + selection: { range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } } }, + _meta: toHostSnapshotAttachmentMeta(undefined), + }]); + + // A snapshotted selection stays on the selection path so the model still receives the selected + // text and range; the read-only signal rides the additionalContext note, not the attachment shape. + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + additionalContext: session.handleUserPromptSubmitted(), + }, { + sendRequests: [{ + prompt: 'what is here?', + attachments: [{ + type: 'selection', + filePath: snapshotUri.fsPath, + displayName: 'snap.txt', + text: 'hello', + selection: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, + }], + }], + additionalContext: { additionalContext: expectedSnapshotReadonlyNote([snapshotUri.fsPath]) }, + }); + }); + + test('keeps a non-text binary snapshot as a read-only file reference (#331154)', async () => { + const snapshotUri = URI.file('/data/attachments/id/document.pdf'); + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('summarize', [{ + type: MessageAttachmentKind.Resource, + label: 'document.pdf', + displayKind: 'document', + uri: snapshotUri.toString(), + _meta: toHostSnapshotAttachmentMeta('application/pdf'), + }]); + + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + additionalContext: session.handleUserPromptSubmitted(), + }, { + sendRequests: [{ + prompt: 'summarize', + attachments: [{ type: 'file', path: snapshotUri.fsPath, displayName: 'document.pdf' }], + }], + additionalContext: { additionalContext: expectedSnapshotReadonlyNote([snapshotUri.fsPath]) }, + }); + }); + + test('sends a snapshotted image as a read-only file reference (#331154)', async () => { + const snapshotUri = URI.file('/data/attachments/id/Pasted Image.png'); + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('what is in this image?', [{ + type: MessageAttachmentKind.Resource, + label: 'Pasted Image', + displayKind: 'image', + uri: snapshotUri.toString(), + _meta: toHostSnapshotAttachmentMeta('image/png'), + }]); + + // The runtime materializes the image from its on-disk path, so it is sent as a file reference + // rather than an inline blob; the read-only signal rides the additionalContext note. + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + additionalContext: session.handleUserPromptSubmitted(), + }, { + sendRequests: [{ + prompt: 'what is in this image?', + attachments: [{ type: 'file', path: snapshotUri.fsPath, displayName: 'Pasted Image' }], + }], + additionalContext: { additionalContext: expectedSnapshotReadonlyNote([snapshotUri.fsPath]) }, + }); + }); + test('sends paste simple attachments as text blobs', async () => { const { session, mockSession } = await createAgentSession(disposables); @@ -4520,6 +4646,39 @@ suite('CopilotAgentSession', () => { }]); }); + test('sends a host-created text snapshot in a steering message as a read-only file reference with a note (#331154)', async () => { + const snapshotUri = URI.file('/session/attachments/pasted.txt'); + const { session, mockSession } = await createAgentSession(disposables); + + await session.sendSteering({ + id: 'steer-text', + message: { + text: 'use this', + origin: { kind: MessageKind.User }, + attachments: [{ + type: MessageAttachmentKind.Resource, + uri: snapshotUri.toString(), + label: 'Pasted text #1', + displayKind: 'document', + _meta: toHostSnapshotAttachmentMeta('text/plain'), + }], + }, + }); + + // Steering can't use the additionalContext hook, so the read-only note is folded into the + // steering prompt as a block (stripped from the bubble, forwarded to the model); + // the attachment keeps its plain display name. + assert.deepStrictEqual(mockSession.sendRequests, [{ + prompt: `use this\n\n\n${expectedSnapshotReadonlyNote([snapshotUri.fsPath])}\n`, + attachments: [{ + type: 'file', + path: snapshotUri.fsPath, + displayName: 'Pasted text #1', + }], + mode: 'immediate', + }]); + }); + test('promotes steering to its own turn when the SDK echoes the user message', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); session.resetTurnState('turn-original'); diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index da0850f9e6b4e..7873d7e9cc01e 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -134,6 +134,35 @@ suite('SessionPermissionManager', () => { assert.deepStrictEqual(results, [ToolCallConfirmationReason.NotNeeded, undefined]); }); + test('isForbiddenSnapshotWrite flags writes to a session attachment snapshot but not reads or working-dir writes (#331154)', () => { + const snapshot = URI.joinPath(sessionDataService.getSessionDataDir(URI.parse(sessionUri)), SESSION_ATTACHMENTS_DIRNAME, 'id', 'Pasted text #1.txt').fsPath; + assert.deepStrictEqual({ + snapshotWrite: permissions.isForbiddenSnapshotWrite(writeEvent(snapshot), sessionUri), + workingDirWrite: permissions.isForbiddenSnapshotWrite(writeEvent(join(workDir, 'app.ts')), sessionUri), + snapshotRead: permissions.isForbiddenSnapshotWrite(readEvent(snapshot), sessionUri), + }, { + snapshotWrite: true, + workingDirWrite: false, + snapshotRead: false, + }); + }); + + test('isForbiddenSnapshotWrite is independent of auto-approve config (used by the interactive deny path) (#331154)', async () => { + configService.updateRootConfig({ [AgentHostGlobalAutoApproveEnabledConfigKey]: true }); + const snapshot = URI.joinPath(sessionDataService.getSessionDataDir(URI.parse(sessionUri)), SESSION_ATTACHMENTS_DIRNAME, 'id', 'Pasted text #1.txt').fsPath; + // getAutoApproval approves the write once global auto-approve is on (the auto-approve checks + // return before the write-path logic). isForbiddenSnapshotWrite ignores that, so _handleToolReady + // can hard-deny before consulting getAutoApproval. NOTE: this only matters when a provider raises + // an interactive pending_confirmation; providers that auto-approve upstream never reach it. + assert.deepStrictEqual({ + autoApproval: await permissions.getAutoApproval(writeEvent(snapshot), sessionUri), + forbidden: permissions.isForbiddenSnapshotWrite(writeEvent(snapshot), sessionUri), + }, { + autoApproval: ToolCallConfirmationReason.Setting, + forbidden: true, + }); + }); + test('requires confirmation for writes outside the working directory', async () => { const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'app.ts')), sessionUri); assert.strictEqual(result, undefined); From 56a267c2a57513aba3d606f9f4271a805a4455b9 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:12:51 +0200 Subject: [PATCH 19/29] agentHost: stop a failed git probe from stalling Agent Merge (#331792) * agentHost: stop a failed git probe from stalling Agent Merge A session with Agent Merge enabled could sit idle forever without ever binding to its pull request, so review comments and CI failures never reached it. `_computeSessionGitState` treats every probe as independent and populates fields best-effort. When `git status` failed -- most often a timeout under load, which writes nothing to stderr and so logged nothing at all -- it still returned an object, just without a branch. `_setSessionGitState` replaces persisted git state wholesale, so that object overwrote the good branch with `{"baseBranchName":"main"}`. Nothing then repaired it. `AgentMergeController._evaluate` bails on a missing branch before it reaches the refresh that would recompute it, and the lazy refresh on subscribe only fires when git state is entirely absent, so a partial state masked it. For a session held resident by Agent Merge alone -- no client watching, no edits landing -- neither of the remaining refresh triggers fires either, leaving it to re-read the same stale state on the 10 minute backstop indefinitely. - Return `undefined` from `_computeSessionGitState` when the status probe fails, so callers keep the state they already had. - Refresh git state in `_evaluate` before giving up on the branch, which also recovers sessions already holding a branch-less state. - Treat a branch-less state as missing in the subscribe-time refresh. - Log git failures that produce no stderr, so a timed-out probe is no longer invisible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: distinguish a detached HEAD from a failed git probe Addresses PR review feedback: keying the repair off a missing `branchName` alone also matched a detached HEAD, which reports no branch by design. Those sessions would have refreshed git state on every evaluation -- a periodic git call and log noise that could never produce a branch. `parseGitStatusV2` already recognises `(detached)`; it now reports that as `isDetachedHead` so the distinction survives into persisted session git state, and a shared `needsSessionGitStateRefresh` predicate keeps the Agent Merge and subscribe-time call sites in agreement about which states are worth recomputing. The controller additionally caps the repair at one attempt per runtime, so any other checkout that cannot report a branch costs a single git call rather than one per backstop, and logs a warning when a refresh still yields no branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/state/sessionState.ts | 25 ++++ .../agentHost/node/agentHostGitService.ts | 34 ++++- .../agentHost/node/agentMergeController.ts | 55 ++++++- .../platform/agentHost/node/agentService.ts | 9 +- .../agentHostGitService.integrationTest.ts | 14 ++ .../test/node/agentHostGitService.test.ts | 22 +++ .../test/node/agentMergeController.test.ts | 135 +++++++++++++++++- 7 files changed, 283 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 4d05f64cd3e66..3cf0ec824c6c9 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1366,6 +1366,12 @@ export interface ISessionGitState { readonly hasGitHubRemote?: boolean; /** Current branch name. */ readonly branchName?: string; + /** + * Whether `HEAD` is detached, which is why {@link branchName} is absent. + * Distinguishes a legitimately branch-less checkout from git state left + * behind by a probe that failed before it could resolve the branch. + */ + readonly isDetachedHead?: boolean; /** Base branch the work targets (e.g. `main`). */ readonly baseBranchName?: string; /** Upstream tracking branch (e.g. `origin/feature`). */ @@ -1581,6 +1587,7 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS const result: { hasGitHubRemote?: boolean; branchName?: string; + isDetachedHead?: boolean; baseBranchName?: string; upstreamBranchName?: string; incomingChanges?: number; @@ -1593,6 +1600,7 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS } = {}; if (typeof raw['hasGitHubRemote'] === 'boolean') { result.hasGitHubRemote = raw['hasGitHubRemote']; } if (typeof raw['branchName'] === 'string') { result.branchName = raw['branchName']; } + if (typeof raw['isDetachedHead'] === 'boolean') { result.isDetachedHead = raw['isDetachedHead']; } if (typeof raw['baseBranchName'] === 'string') { result.baseBranchName = raw['baseBranchName']; } if (typeof raw['upstreamBranchName'] === 'string') { result.upstreamBranchName = raw['upstreamBranchName']; } if (typeof raw['incomingChanges'] === 'number') { result.incomingChanges = raw['incomingChanges']; } @@ -1605,6 +1613,23 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS return result; } +/** + * Whether a session's git state should be recomputed because it does not + * describe a usable checkout. + * + * A state that was never computed obviously qualifies. So does one that is + * missing its branch without a detached `HEAD` to explain it: `git status` is + * the only probe that reports the branch, so such a state is the residue of a + * probe that failed, and consumers that key off the branch (Agent Merge binds + * its pull request that way) stay stranded until it is recomputed. A detached + * `HEAD` is a legitimate branch-less checkout and must not be mistaken for it, + * or every caller would refresh in a loop against a repository that will never + * report a branch. + */ +export function needsSessionGitStateRefresh(gitState: ISessionGitState | undefined): boolean { + return gitState === undefined || (gitState.branchName === undefined && !gitState.isDetachedHead); +} + /** * Returns a new {@link SessionMeta} with the git-state payload set to * `gitState`, or with the git slot removed if `gitState` is `undefined`. diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 891246628310f..1772836f753aa 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -947,6 +947,18 @@ export class AgentHostGitService implements IAgentHostGitService { configuredBaseBranch ? undefined : this._runGit(repositoryRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']), ]); + // `git status` is the only probe that reports the branch, so a state + // computed without it is not merely incomplete — it is misleading. + // Callers persist the result wholesale, so returning a branch-less + // object here would overwrite the last known good branch and strand + // every consumer that keys off it (Agent Merge binds its pull request + // by branch). Report the failure instead and let callers keep what + // they already had. + if (statusOutput === undefined) { + this._logService.warn(`[agentHostGitService] Not reporting session git state because git status failed: ${repositoryRoot.fsPath}`); + return undefined; + } + const status = parseGitStatusV2(statusOutput); const hasGitHubRemote = parseHasGitHubRemote(remotesOutput); const baseBranchName = configuredBaseBranch ?? parseDefaultBranchRef(defaultBranchRef); @@ -982,6 +994,7 @@ export class AgentHostGitService implements IAgentHostGitService { const result: ISessionGitState = { hasGitHubRemote, branchName: status.branchName, + isDetachedHead: status.isDetachedHead, baseBranchName, upstreamBranchName: status.upstreamBranchName, incomingChanges: status.incomingChanges, @@ -1048,6 +1061,16 @@ export class AgentHostGitService implements IAgentHostGitService { // raw progress/diagnostic text is still available. if (stderr) { this._logService.warn(`[agentHostGitService] > git ${args.join(' ')} failed; full stderr:\n${stderr}`); + } else if (didTimeOut || error.killed) { + // A timed-out or signalled git writes nothing to stderr, + // so this is the only trace such a failure ever leaves. + // Callers that degrade quietly on `undefined` are then + // impossible to diagnose from logs alone. + this._logService.warn(`[agentHostGitService] > git ${args.join(' ')} failed: ${formatGitError(args, timeoutMs, didTimeOut, error, stderr)}`); + } else { + // A silent non-zero exit is how the `--quiet` probes + // report "not found", so this stays below `warn`. + this._logService.trace(`[agentHostGitService] > git ${args.join(' ')} failed: ${formatGitError(args, timeoutMs, didTimeOut, error, stderr)}`); } if (options?.throwOnError) { reject(new Error(formatGitError(args, timeoutMs, didTimeOut, error, stderr), { cause: error })); @@ -1509,6 +1532,7 @@ export function parseGitDiffRawNumstat(output: string, repositoryRoot: URI, sess */ export function parseGitStatusV2(output: string | undefined): { branchName?: string; + isDetachedHead?: boolean; upstreamBranchName?: string; outgoingChanges?: number; incomingChanges?: number; @@ -1518,6 +1542,7 @@ export function parseGitStatusV2(output: string | undefined): { return {}; } let branchName: string | undefined; + let isDetachedHead: boolean | undefined; let upstreamBranchName: string | undefined; let outgoingChanges: number | undefined; let incomingChanges: number | undefined; @@ -1527,8 +1552,11 @@ export function parseGitStatusV2(output: string | undefined): { if (!line) { continue; } if (line.startsWith('# branch.head ')) { const head = line.substring('# branch.head '.length).trim(); - // `(detached)` is what git emits for a detached HEAD. Treat as no branch. - branchName = head === '(detached)' ? undefined : head; + // `(detached)` is what git emits for a detached HEAD. Treat as no + // branch, but report why so consumers can tell an intentionally + // branch-less checkout from a status probe that never ran. + isDetachedHead = head === '(detached)' ? true : undefined; + branchName = isDetachedHead ? undefined : head; } else if (line.startsWith('# branch.upstream ')) { upstreamBranchName = line.substring('# branch.upstream '.length).trim(); } else if (line.startsWith('# branch.ab ')) { @@ -1541,7 +1569,7 @@ export function parseGitStatusV2(output: string | undefined): { uncommittedChanges++; } } - return { branchName, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges }; + return { branchName, isDetachedHead, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges }; } /** Exported for tests. */ diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 7563e195929d9..9fab75587c62b 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -21,7 +21,7 @@ import { deriveGitHubEndpoints } from '../common/githubEndpoints.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ActionType } from '../common/state/protocol/common/actions.js'; import { AuthRequiredReason } from '../common/state/sessionActions.js'; -import { getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, parseRequiredSessionUriFromChatUri, readSessionGitHubState, readSessionGitState, SessionLifecycle, TurnState } from '../common/state/sessionState.js'; +import { getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, needsSessionGitStateRefresh, parseRequiredSessionUriFromChatUri, readSessionGitHubState, readSessionGitState, SessionLifecycle, TurnState } from '../common/state/sessionState.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; @@ -47,6 +47,13 @@ class AgentMergeRuntime extends Disposable { readonly evaluationScheduler: RunOnceScheduler; readonly backstopScheduler: RunOnceScheduler; ref: PullRequestRef | undefined; + /** + * Whether this runtime already tried to recompute git state that reported + * no usable branch. Caps that repair at one git call per runtime so a + * checkout that can never report a branch does not spawn one on every + * backstop. + */ + didRefreshForMissingBranch = false; constructor( readonly session: string, @@ -337,8 +344,10 @@ export class AgentMergeController extends Disposable { if (!runtime || !state || !agentMerge?.enabled || this._stateManager.hasActiveTurn(session)) { return; } - const gitState = readSessionGitState(state._meta); - const branchName = gitState?.branchName; + const branchName = await this._resolveCurrentBranch(session, runtime, state); + if (!this._isCurrentRuntime(session, runtime)) { + return; + } if (!branchName) { this._logService.trace(`[AgentMergeController] Waiting for a current branch: session=${session}`); runtime.backstopScheduler.schedule(); @@ -479,6 +488,46 @@ export class AgentMergeController extends Disposable { } } + /** + * Resolves the branch Agent Merge should act on, repairing session git + * state that does not report one. + * + * A failed git probe can leave persisted git state without a branch. The + * refresh that would repair it normally rides along with a client watching + * the session or an edit landing in the worktree, and neither happens for a + * session this controller is holding resident on its own. Every later step + * — binding the pull request, subscribing to it, acting on its feedback — + * is gated on the branch, so without this the session idles on the backstop + * indefinitely and Agent Merge silently never runs. + * + * A detached `HEAD` is excluded: it reports no branch by design, so + * refreshing would never produce one. The attempt is capped at once per + * runtime regardless, so any other checkout that cannot report a branch + * costs a single git call rather than one per backstop. + */ + private async _resolveCurrentBranch(session: string, runtime: AgentMergeRuntime, state: NonNullable>): Promise { + const gitState = readSessionGitState(state._meta); + if (gitState?.branchName) { + return gitState.branchName; + } + if (runtime.didRefreshForMissingBranch || !needsSessionGitStateRefresh(gitState)) { + return undefined; + } + runtime.didRefreshForMissingBranch = true; + this._logService.debug(`[AgentMergeController] Refreshing git state because the session reports no branch: session=${session}`); + await this._gitStateService.refreshSessionGitState(session, state.workingDirectories?.[0] ? URI.parse(state.workingDirectories[0]) : undefined); + if (!this._isCurrentRuntime(session, runtime)) { + return undefined; + } + const refreshed = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.branchName; + if (refreshed) { + this._logService.info(`[AgentMergeController] Recovered the session branch after refreshing git state: session=${session}`); + } else { + this._logService.warn(`[AgentMergeController] Session still reports no branch after refreshing git state: session=${session}`); + } + return refreshed; + } + private async _resolveRef(parsed: IParsedPullRequestUrl, signal: AbortSignal): Promise { const credential = await this._gitHubService.credentials.getCredential(signal); // The bound pull request URL carries its own host: after a restore or an diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9c2cb2258d810..865413420411f 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -3941,9 +3941,12 @@ export class AgentService extends Disposable implements IAgentService { // restore path that normally calls `_attachGitState` is skipped — so // trigger it lazily here for the first subscriber. `_attachGitState` // is async and updates `_meta.git` once ready, which clients see via - // the normal state-update stream. + // the normal state-update stream. State that does not describe a + // usable checkout counts as missing too: a failed probe can persist + // a branch-less remnant, and it would otherwise mask the very + // repair this lazy refresh exists to perform. const sessionState = this._stateManager.getSessionState(resourceStr); - if (!isAhpChatChannel(resourceStr) && sessionState && readSessionGitState(sessionState._meta) === undefined) { + if (!isAhpChatChannel(resourceStr) && sessionState && needsSessionGitStateRefresh(readSessionGitState(sessionState._meta))) { const workingDirectory = sessionState.workingDirectories?.[0] ? URI.parse(sessionState.workingDirectories[0]) : undefined; diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index bdace033db24e..1a1f5c8bd568d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -192,6 +192,20 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { assert.strictEqual(result.hasGitHubRemote, false); }); + (hasGit ? test : test.skip)('reports no state at all when the status probe fails', async () => { + const dir = initRepo({ remote: 'https://github.com/owner/repo.git' }); + const before = await svc!.getSessionGitState(URI.file(dir)); + // The repository root is cached from the call above, so the probes still + // run against a repository that can no longer answer them — the same + // shape a probe takes when it times out under load. A partial state + // would be persisted over the branch this session still depends on. + rmDirWithRetry(join(dir, '.git')); + + const after = await svc!.getSessionGitState(URI.file(dir)); + + assert.deepStrictEqual({ before: before?.branchName, after }, { before: 'main', after: undefined }); + }); + (hasGit ? test : test.skip)('reports outgoingChanges relative to base branch when local branch has no upstream', async () => { // Create a bare "remote" repo and set up the working repo so that // `refs/remotes/origin/HEAD` exists (required for baseBranchName parsing). diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index d01bc18bbc823..ec4e1f44acf80 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -9,6 +9,7 @@ import { formatGitError, getRemoteTrackingRef, GitCheckoutProgressParser, isRetr import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; import { EMPTY_TREE_OBJECT, getBranchCompletions, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; +import { needsSessionGitStateRefresh } from '../../common/state/sessionState.js'; suite('AgentHostGitService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -104,6 +105,7 @@ suite('AgentHostGitService', () => { ].join('\n'); assert.deepStrictEqual(parseGitStatusV2(out), { branchName: 'main', + isDetachedHead: undefined, upstreamBranchName: 'origin/main', outgoingChanges: 0, incomingChanges: 0, @@ -123,6 +125,7 @@ suite('AgentHostGitService', () => { ].join('\n'); assert.deepStrictEqual(parseGitStatusV2(out), { branchName: 'feature', + isDetachedHead: undefined, upstreamBranchName: 'origin/feature', outgoingChanges: 3, incomingChanges: 2, @@ -137,6 +140,7 @@ suite('AgentHostGitService', () => { ].join('\n'); assert.deepStrictEqual(parseGitStatusV2(out), { branchName: undefined, + isDetachedHead: true, upstreamBranchName: undefined, outgoingChanges: undefined, incomingChanges: undefined, @@ -149,6 +153,24 @@ suite('AgentHostGitService', () => { }); }); + suite('needsSessionGitStateRefresh', () => { + test('separates a branch-less probe failure from a detached HEAD', () => { + assert.deepStrictEqual({ + neverComputed: needsSessionGitStateRefresh(undefined), + // The residue of a failed `git status`, as persisted before the + // probe learned to withhold state it could not compute. + probeFailureRemnant: needsSessionGitStateRefresh({ baseBranchName: 'main' }), + detachedHead: needsSessionGitStateRefresh({ isDetachedHead: true, baseBranchName: 'main' }), + onABranch: needsSessionGitStateRefresh({ branchName: 'feature', baseBranchName: 'main' }), + }, { + neverComputed: true, + probeFailureRemnant: true, + detachedHead: false, + onABranch: false, + }); + }); + }); + suite('parseHasGitHubRemote', () => { test('detects ssh github remote', () => { assert.strictEqual(parseHasGitHubRemote('origin\tgit@github.com:owner/repo.git (fetch)\n'), true); diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index 149dba8e090e0..d49b0e0c00d9c 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { timeout } from '../../../../base/common/async.js'; import { NullLogService } from '../../../log/common/log.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { mock } from '../../../../base/test/common/mock.js'; @@ -13,7 +14,7 @@ import { AgentHostAutoApprovePolicyRestrictedConfigKey, platformRootSchema, plat import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import { SessionStatus, buildDefaultChatUri, MessageKind, type SessionSummary } from '../../common/state/sessionState.js'; +import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { IGitHubService } from '../../../github/common/githubService.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; @@ -218,6 +219,136 @@ suite('AgentMergeController', () => { }); }); + test('recovers a session whose persisted git state lost its branch', async () => { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const session = `copilot:/agent-merge-controller-${++sessionCounter}`; + let refreshCount = 0; + const gitStateService = new class extends mock() { + override readonly onDidRefreshSessionGitState = Event.None; + override readonly onDidChangeSessionGitHubState = Event.None; + override async refreshSessionGitState(sessionKey: string): Promise { + refreshCount++; + stateManager.setSessionMeta(sessionKey, withSessionGitState(stateManager.getSessionState(sessionKey)?._meta, { branchName: 'feature', baseBranchName: 'main' })); + } + // The follow-up evaluation triggered by capturing the target reaches + // this; it finds no pull request and idles on the backstop. + override async attachSessionGitHubPullRequest(): Promise { } + }(); + const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); + disposables.add(new AgentMergeController( + { + startTurn: () => false, + cancelTurn: () => { }, + getAutonomousSessionConfig: () => ({}), + }, + stateManager, + configurationService, + gitStateService, + new class extends mock() { }(), + endpointService, + logService, + )); + stateManager.createSession(summary(session)); + stateManager.setSessionConfig(session, { + schema: platformSessionSchema.toProtocol(), + values: {}, + }); + // A failed git probe leaves the branch behind but keeps the base branch, + // which is exactly the state that used to stall Agent Merge forever. + stateManager.setSessionMeta(session, withSessionGitState(undefined, { baseBranchName: 'main' })); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { enabled: true }, + }); + + const captured = new Promise(resolve => { + disposables.add(stateManager.onDidChangeSessionConfig(event => { + if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.target) { + resolve(); + } + })); + }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + await captured; + + assert.deepStrictEqual({ + refreshCount, + branchName: readAgentMergeSessionState(configurationService.getSessionConfigValues(session))?.target?.branchName, + }, { + refreshCount: 1, + branchName: 'feature', + }); + }); + + test('recomputes git state at most once per runtime and never for a detached HEAD', async () => { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const detached = `copilot:/agent-merge-controller-${++sessionCounter}`; + const stranded = `copilot:/agent-merge-controller-${++sessionCounter}`; + const refreshCounts = new Map(); + const onDidRefreshSessionGitState = disposables.add(new Emitter()); + const gitStateService = new class extends mock() { + override readonly onDidRefreshSessionGitState = onDidRefreshSessionGitState.event; + override readonly onDidChangeSessionGitHubState = Event.None; + // Stands in for a checkout that cannot report a branch however often + // it is probed, which is what makes an unbounded retry expensive. + override async refreshSessionGitState(sessionKey: string): Promise { + refreshCounts.set(sessionKey, (refreshCounts.get(sessionKey) ?? 0) + 1); + } + override async attachSessionGitHubPullRequest(): Promise { } + }(); + const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); + disposables.add(new AgentMergeController( + { + startTurn: () => false, + cancelTurn: () => { }, + getAutonomousSessionConfig: () => ({}), + }, + stateManager, + configurationService, + gitStateService, + new class extends mock() { }(), + endpointService, + logService, + )); + for (const [session, gitState] of [ + [detached, { isDetachedHead: true, baseBranchName: 'main' }], + [stranded, { baseBranchName: 'main' }], + ] as const) { + stateManager.createSession(summary(session)); + stateManager.setSessionConfig(session, { + schema: platformSessionSchema.toProtocol(), + values: {}, + }); + stateManager.setSessionMeta(session, withSessionGitState(undefined, gitState)); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { enabled: true }, + }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + } + + // Drive several evaluation cycles; without a guard each one would spawn + // another git call for both sessions. + for (let cycle = 0; cycle < 3; cycle++) { + onDidRefreshSessionGitState.fire(detached); + onDidRefreshSessionGitState.fire(stranded); + await timeout(0); + await timeout(0); + } + + assert.deepStrictEqual({ + detached: refreshCounts.get(detached) ?? 0, + stranded: refreshCounts.get(stranded) ?? 0, + }, { + detached: 0, + stranded: 1, + }); + }); + function createControllerHarness(disposables: ReturnType): { readonly stateManager: AgentHostStateManager; readonly configurationService: AgentConfigurationService; From 919e585688744d93823b9552ca14dae8d7099a44 Mon Sep 17 00:00:00 2001 From: Dileep Yavanmandha <52841896+dileepyavan@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:15:33 -0700 Subject: [PATCH 20/29] Disabling sandbox toggle when managed settings are enabled for sandbox. (#331803) * Removing managed settings check * updating types for sandbox configuration * fixing compile errors * updates to disable sandbox toggle when managed settings are enabled --- .../agentHostPermissionPickerDelegate.ts | 4 +++ .../agentHostPermissionPickerDelegate.test.ts | 20 ++++++++++++- .../browser/permissionPicker.ts | 28 +++++++++++++++++-- .../agentHost/agentHostChatInputPicker.ts | 19 ++++++++++++- .../input/permissionPickerActionItem.ts | 27 ++++++++++++++++-- 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 2dd2d6a76cd79..39829d7e81b78 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -7,6 +7,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/ import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; import { AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId, getAgentHostCopilotSandboxSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AgentHostCustomTerminalToolEnabledSettingId } from '../../../../../platform/agentHost/common/copilotCliConfig.js'; import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { narrowClaudePermissionMode } from '../../../../../platform/agentHost/common/claudeSessionConfigKeys.js'; @@ -74,6 +75,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe readonly isApplicable: IObservable; readonly isResolving: IObservable; readonly sandboxTogglePresentation = 'standalone' as const; + readonly managedSandboxEnforced: IObservable; readonly sandboxToggleConfigurationKeys = [ AgentHostCustomTerminalToolEnabledSettingId, AgentHostSdkSandboxEnabledSettingId, @@ -132,8 +134,10 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe private readonly _session: IObservable, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, ) { super(); + this.managedSandboxEnforced = agentHostEnablementService.managedSandboxEnforced; this._watchProviders(this._sessionsProvidersService.getProviders()); this._register(this._sessionsProvidersService.onDidChangeProviders(e => { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index 0d0fd622c8893..6fb98067d5ef9 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -6,13 +6,14 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { type IConfigurationOverrides, IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ResolveSessionConfigResult, SessionConfigPropertySchema } from '../../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { getAgentHostCopilotSandboxSettingId } from '../../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AgentHostCustomTerminalToolEnabledSettingId } from '../../../../../../../platform/agentHost/common/copilotCliConfig.js'; import type { RootConfigState } from '../../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../../workbench/contrib/chat/common/constants.js'; @@ -87,6 +88,7 @@ interface ITestRig { readonly activeSessionObs: ReturnType>; readonly setAssistedPermissionsEnabled: (enabled: boolean) => void; readonly setCustomTerminalToolEnabled: (enabled: boolean) => void; + readonly setManagedSandboxEnforced: (enforced: boolean) => void; } function setup(store: Pick, activeSession: IActiveSession | undefined, configValue?: string): ITestRig { @@ -104,6 +106,7 @@ function setup(store: Pick, activeSession: IActiveSessio } })(); const activeSessionObs = observableValue('activeSession', activeSession); + const managedSandboxEnforced = observableValue('managedSandboxEnforced', false); let assistedPermissionsEnabled = true; let customTerminalToolEnabled = false; const configurationService = new class extends mock() { @@ -127,6 +130,11 @@ function setup(store: Pick, activeSession: IActiveSessio insta.set(ISessionsService, sessionsManagementService); insta.set(ISessionsProvidersService, sessionsProvidersService); insta.set(IConfigurationService, configurationService); + insta.set(IAgentHostEnablementService, { + _serviceBrand: undefined, + enabled: constObservable(true), + managedSandboxEnforced, + }); const delegate = store.add(insta.createInstance(AgentHostPermissionPickerDelegate, activeSessionObs)); return { @@ -135,6 +143,7 @@ function setup(store: Pick, activeSession: IActiveSessio activeSessionObs, setAssistedPermissionsEnabled: enabled => assistedPermissionsEnabled = enabled, setCustomTerminalToolEnabled: enabled => customTerminalToolEnabled = enabled, + setManagedSandboxEnforced: enforced => managedSandboxEnforced.set(enforced, undefined), }; } @@ -177,6 +186,15 @@ suite('AgentHostPermissionPickerDelegate', () => { }); }); + test('exposes managed sandbox enforcement to picker surfaces', () => { + const { delegate, setManagedSandboxEnforced } = setup(store, makeActiveSession(), 'default'); + const before = delegate.managedSandboxEnforced.get(); + + setManagedSandboxEnforced(true); + + assert.deepStrictEqual({ before, after: delegate.managedSandboxEnforced.get() }, { before: false, after: true }); + }); + test('returns Default when the active session has no config seeded yet', () => { const { delegate } = setup(store, makeActiveSession()); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index 1111985083d5a..17ef3412c0078 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -86,6 +86,7 @@ export interface IPermissionPickerDelegate { readonly isSandboxToggleApplicable?: () => boolean; readonly sandboxTogglePresentation?: 'standalone'; readonly getSandboxToggleSettingId?: () => string | undefined; + readonly managedSandboxEnforced?: IObservable; readonly sandboxToggleConfigurationKeys?: readonly string[]; } @@ -240,6 +241,13 @@ export class PermissionPicker extends Disposable { trigger.setAttribute('aria-disabled', resolving ? 'true' : 'false'); })); } + const managedSandboxEnforced = this._delegate.managedSandboxEnforced; + if (managedSandboxEnforced) { + this._renderDisposables.add(autorun(reader => { + managedSandboxEnforced.read(reader); + this._updateTriggerLabel(trigger); + })); + } this._renderDisposables.add(this.configurationService.onDidChangeConfiguration(e => { if (this._affectsSandboxToggle(e)) { this._updateTriggerLabel(trigger); @@ -283,6 +291,7 @@ export class PermissionPicker extends Disposable { const sandboxToggle = this._getSandboxStandaloneToggle(); if (sandboxToggle) { + const disabled = sandboxToggle.disabled === true; items.push({ kind: ActionListItemKind.Separator, label: '', @@ -299,7 +308,8 @@ export class PermissionPicker extends Disposable { }, label: sandboxToggle.label, standaloneToggle: sandboxToggle, - disabled: false, + ...(disabled ? { hover: { content: localize('permissions.policyDescription', "Disabled by enterprise policy") } } : {}), + disabled, }); } @@ -415,11 +425,18 @@ export class PermissionPicker extends Disposable { if (!this._isSandboxToggleAvailable()) { return undefined; } + const managed = this._isSandboxManaged(); return { label: localize('permissionPicker.sandboxToggle', "Sandboxing for terminal"), - title: localize('permissionPicker.sandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), + title: managed + ? localize('permissionPicker.managedSandboxToggleTitle', "Sandboxing is managed by your organization") + : localize('permissionPicker.sandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), checked: this._isSandboxingEnabled(), + disabled: managed, onChange: (checked: boolean) => { + if (this._isSandboxManaged()) { + return; + } const settingId = this._delegate.getSandboxToggleSettingId?.(); if (settingId) { const target = checked ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; @@ -437,11 +454,18 @@ export class PermissionPicker extends Disposable { } private _isSandboxingEnabled(): boolean { + if (this._isSandboxManaged()) { + return true; + } const settingId = this._delegate.getSandboxToggleSettingId?.(); return settingId !== undefined && isAgentSandboxEnabledValue(this.configurationService.getValue(settingId)); } + private _isSandboxManaged(): boolean { + return this._delegate.managedSandboxEnforced?.get() === true; + } + private _affectsSandboxToggle(event: IConfigurationChangeEvent): boolean { const settingId = this._delegate.getSandboxToggleSettingId?.(); return event.affectsConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 24dd2294618b4..ed9d0a3fab880 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -12,12 +12,14 @@ import { Delayer } from '../../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { IActionListOptions, ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemInlineToggle } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { getCodexApprovalsPickerListOptions } from '../../../../../../platform/agentHost/browser/codexApprovalsPicker.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AgentHostCopilotSandboxSettingId, getAgentHostCopilotSandboxSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { AgentHostCustomTerminalToolEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -369,6 +371,7 @@ export class AgentHostChatInputPicker extends Disposable { @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, @IDialogService private readonly _dialogService: IDialogService, @IStorageService private readonly _storageService: IStorageService, + @IAgentHostEnablementService private readonly _agentHostEnablementService: IAgentHostEnablementService, ) { super(); @@ -390,6 +393,10 @@ export class AgentHostChatInputPicker extends Disposable { } })); this._reattach(); + this._register(autorun(reader => { + this._agentHostEnablementService.managedSandboxEnforced.read(reader); + this._refreshTrigger(); + })); } private _registerInitialResolveCts(): MutableDisposable { @@ -707,6 +714,9 @@ export class AgentHostChatInputPicker extends Disposable { } private _isSandboxingEnabled(): boolean { + if (this._agentHostEnablementService.managedSandboxEnforced.get()) { + return true; + } const settingId = this._getSandboxSettingId(); return settingId !== undefined && isAgentSandboxEnabledValue(this._configurationService.getValue(settingId)); } @@ -716,11 +726,18 @@ export class AgentHostChatInputPicker extends Disposable { if (this._property !== SessionConfigKey.AutoApprove || !this._isSandboxToggleSettingEnabled() || !settingId) { return undefined; } + const managed = this._agentHostEnablementService.managedSandboxEnforced.get(); return { label: localize('agentHostChatInputPicker.defaultSandboxToggle', "Sandboxing for terminal"), - title: localize('agentHostChatInputPicker.defaultSandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), + title: managed + ? localize('agentHostChatInputPicker.managedSandboxToggleTitle', "Sandboxing is managed by your organization") + : localize('agentHostChatInputPicker.defaultSandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), checked: this._isSandboxingEnabled(), + disabled: managed, onChange: checked => { + if (this._agentHostEnablementService.managedSandboxEnforced.get()) { + return; + } const target = checked ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; void this._configurationService.updateValue(settingId, target); }, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index 66b4387ac7a6b..212622fb84700 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -8,7 +8,7 @@ import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabe import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { IObservable } from '../../../../../../base/common/observable.js'; +import { autorun, IObservable } from '../../../../../../base/common/observable.js'; import { isWindows } from '../../../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; @@ -69,6 +69,7 @@ export interface IPermissionPickerDelegate { readonly sandboxTogglePresentation?: 'inline' | 'standalone'; readonly getSandboxToggleSettingId?: () => string | undefined; readonly sandboxToggleConfigurationKeys?: readonly string[]; + readonly managedSandboxEnforced?: IObservable; } /** Default level set offered when a delegate does not specify {@link IPermissionPickerDelegate.availableLevels}. */ @@ -199,16 +200,23 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { const sandboxToggleEnabled = this.isSandboxToggleAvailable(); const sandboxTogglePresentation = delegate.sandboxTogglePresentation ?? 'inline'; const setSandboxEnabled = async (enableSandbox: boolean) => { + if (this.isSandboxManaged()) { + return; + } const target: AgentSandboxEnabledValue = enableSandbox ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; const settingId = this.getSandboxToggleSettingId(); if (settingId && this.isSandboxingEnabled() !== enableSandbox) { await configurationService.updateValue(settingId, target); } }; + const sandboxManaged = this.isSandboxManaged(); const sandboxToggle = sandboxToggleEnabled ? { label: localize('permissions.default.sandbox.toggle', "Sandboxing for terminal"), - title: localize('permissions.default.sandbox.toggle.title', "Run terminal commands inside a sandbox that restricts file system and network access"), + title: sandboxManaged + ? localize('permissions.default.sandbox.toggle.managedTitle', "Sandboxing is managed by your organization") + : localize('permissions.default.sandbox.toggle.title', "Run terminal commands inside a sandbox that restricts file system and network access"), checked: this.isSandboxingEnabled(), + disabled: sandboxManaged, onChange: (checked: boolean) => { void setSandboxEnabled(checked); }, } : undefined; const levels = delegate.availableLevels ?? DEFAULT_PERMISSION_LEVELS; @@ -301,9 +309,20 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { this.renderLabel(this.element); } })); + if (delegate.managedSandboxEnforced) { + this._register(autorun(reader => { + delegate.managedSandboxEnforced?.read(reader); + if (this.element) { + this.renderLabel(this.element); + } + })); + } } private isSandboxingEnabled(): boolean { + if (this.isSandboxManaged()) { + return true; + } const settingId = this.getSandboxToggleSettingId(); if (!settingId) { return false; @@ -312,6 +331,10 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { return isAgentSandboxEnabledValue(value); } + private isSandboxManaged(): boolean { + return this.delegate.managedSandboxEnforced?.get() === true; + } + private getSandboxToggleSettingId(): string | undefined { return this.delegate.getSandboxToggleSettingId ? this.delegate.getSandboxToggleSettingId() From 78cd2148fadf4bcf8aa5c89257865b1dfdc89285 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 23:01:23 +0200 Subject: [PATCH 21/29] sessions: Move chat actions to overflow menu (#331857) * sessions: move chat actions to overflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: align command center workspace label Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address chat header review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: label workspace-less command center Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: label sessions without workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: provide complete session workspace fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: add new chat to session item menu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: separate new chat list action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: disable side chats without workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/chatCompositeBar.ts | 95 +++++--- .../sessions/browser/parts/chatGroupView.ts | 4 + .../sessions/browser/parts/chatGroupsView.ts | 2 + .../browser/parts/media/chatCompositeBar.css | 117 ++++------ .../sessionConversationsActionViewItem.ts | 138 ------------ .../sessions/browser/parts/sessionHeader.ts | 54 ++--- src/vs/sessions/browser/parts/sessionView.ts | 8 + .../browser/sessionConversationGroups.ts | 8 - src/vs/sessions/browser/sessionWorkspace.ts | 2 +- src/vs/sessions/common/contextkeys.ts | 2 +- .../browser/media/sessionsTitleBarWidget.css | 39 ++-- .../sessions/browser/sessions.contribution.ts | 6 +- .../sessions/browser/sessionsActions.ts | 205 +++++------------- .../browser/sessionsTitleBarWidget.ts | 94 +++----- .../browser/views/sessionsViewActions.ts | 12 +- .../test/browser/sessionsActions.test.ts | 107 +++++++++ .../sessions/common/sessionContextKeys.ts | 3 +- .../test/browser/chatCompositeBar.test.ts | 19 +- .../test/browser/chatGroupsView.test.ts | 36 +-- .../browser/sessionConversationGroups.test.ts | 120 +--------- .../test/browser/sessionHeader.test.ts | 26 ++- .../sessions/chatCompositeBar.fixture.ts | 2 + .../sessionsTitleBarWidget.fixture.ts | 24 +- 23 files changed, 441 insertions(+), 682 deletions(-) delete mode 100644 src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts create mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 13c6658ff7216..d128c870b9354 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -39,6 +39,11 @@ import { ISessionsProvidersService } from '../../services/sessions/browser/sessi import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js'; +import { MenuItemAction } from '../../../platform/actions/common/actions.js'; +import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js'; +import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; +import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; +import { getSessionConversationStatusAriaLabel } from '../sessionConversationGroups.js'; interface IChatTab { readonly chat: IChat; @@ -57,7 +62,7 @@ export interface IChatCompositeBarDelegate { /** * The session whose chats are partitioned across groups. The bar reads it for * the contributed tab menus (whose actions act on `{ session, chat }`), chat - * capabilities, rename/delete, and the trailing "New Chat" gating. + * drag data, and rename/delete operations. */ readonly session: IActiveSession; @@ -73,6 +78,9 @@ export interface IChatCompositeBarDelegate { /** Whether the tab strip should be shown. */ readonly visible: IObservable; + /** Whether this single group's tab row replaces the session header and shows its actions. */ + readonly showSessionActions: IObservable; + /** Activate (show + focus) the given chat within this group. */ openChat(resource: URI): void; @@ -100,6 +108,12 @@ export class ChatCompositeBar extends Disposable { private readonly _tabsRow: HTMLElement; private readonly _tabsContainer: HTMLElement; private readonly _tabsScrollbar: ScrollableElement; + private readonly _newChatAction: Action; + private readonly _newChatContainer: HTMLElement; + private readonly _sessionActionsContainer: HTMLElement; + private readonly _sessionToolbar: MenuWorkbenchToolBar; + private readonly _metaRow: HTMLElement; + private readonly _metaToolbar: MenuWorkbenchToolBar; private readonly _tabs: IChatTab[] = []; private readonly _tabDisposables = this._register(new DisposableStore()); @@ -107,8 +121,7 @@ export class ChatCompositeBar extends Disposable { private readonly _editingDisposables = this._register(new MutableDisposable()); private _editingTab: IChatTab | undefined; private _delegate: IChatCompositeBarDelegate | undefined; - private readonly _newChatAction: Action; - private readonly _newChatContainer: HTMLElement; + private _showSessionActions = false; private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -139,6 +152,7 @@ export class ChatCompositeBar extends Disposable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @ICommandService private readonly _commandService: ICommandService, + @ISessionsService sessionsService: ISessionsService, ) { super(); @@ -159,6 +173,43 @@ export class ChatCompositeBar extends Disposable { })); this._tabsRow.appendChild(this._tabsScrollbar.getDomNode()); + this._newChatAction = this._register(new Action( + 'sessions.chatCompositeBar.addChat', + localize('chatCompositeBar.addChat', "New Chat in This Session"), + ThemeIcon.asClassName(Codicon.add), + true, + async () => this._delegate?.newChat(), + )); + const newChatActionBar = this._register(new ActionBar(this._tabsRow)); + newChatActionBar.push(this._newChatAction, { icon: true, label: false }); + this._newChatContainer = newChatActionBar.getContainer(); + this._newChatContainer.classList.add('chat-composite-bar-new-chat'); + + this._sessionActionsContainer = $('.session-chat-tabs-actions'); + this._tabsRow.appendChild(this._sessionActionsContainer); + const sessionToolbarContainer = $('.chat-composite-bar-toolbar'); + this._sessionActionsContainer.appendChild(sessionToolbarContainer); + this._sessionToolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, sessionToolbarContainer, Menus.SessionBarToolbar, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + menuOptions: { shouldForwardArgs: true }, + highlightToggledItems: true, + })); + + this._metaRow = $('.chat-composite-bar-meta-row'); + this._container.appendChild(this._metaRow); + const metaToolbarContainer = $('.chat-composite-bar-meta-toolbar'); + this._metaRow.appendChild(metaToolbarContainer); + const metaActionRunner = this._register(new SessionActivatingActionRunner(() => this._delegate?.session, sessionsService)); + this._metaToolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, metaToolbarContainer, Menus.SessionHeaderMeta, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + menuOptions: { shouldForwardArgs: true }, + actionRunner: metaActionRunner, + actionViewItemProvider: (action, options) => action instanceof MenuItemAction + ? this._instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options) + : undefined, + })); + this._register(this._metaToolbar.onDidChangeMenuItems(() => this._updateMetaRowVisibility())); + const preventMiddleButtonDefault = (e: MouseEvent) => { if (e.button === 1 && !this._isInTabInput(e)) { e.preventDefault(); @@ -170,21 +221,6 @@ export class ChatCompositeBar extends Disposable { this._register(addDisposableGenericMouseUpListener(this._tabsContainer, preventMiddleButtonDefault)); } - // "New Chat" button pinned at the end of the tab strip. Starting a new chat - // is offered here while the tabs are shown; when the session has a single - // chat the session header toolbar offers it instead. - const newChatAction = this._newChatAction = this._register(new Action( - 'chatCompositeBar.addChat', - localize('chatCompositeBar.addChat', "New Chat"), - ThemeIcon.asClassName(Codicon.add), - true, - async () => this._delegate?.newChat(), - )); - const newChatActionBar = this._register(new ActionBar(this._tabsRow, { actionViewItemProvider: undefined })); - newChatActionBar.push(newChatAction, { icon: true, label: false }); - this._newChatContainer = newChatActionBar.getContainer(); - this._newChatContainer.classList.add('chat-composite-bar-new-chat'); - // Keep the visual scrollbar in sync with native scrolling inside the tabs container this._register(addDisposableListener(this._tabsContainer, EventType.SCROLL, () => { this._tabsScrollbar.setScrollPosition({ scrollLeft: this._tabsContainer.scrollLeft }); @@ -225,6 +261,8 @@ export class ChatCompositeBar extends Disposable { } this._delegate = delegate; + this._sessionToolbar.context = delegate?.session; + this._metaToolbar.context = delegate?.session; const store = new DisposableStore(); this._groupDisposables.value = store; @@ -242,21 +280,22 @@ export class ChatCompositeBar extends Disposable { const activeChatUri = delegate.activeChatResource.read(reader); const mainChatUri = delegate.mainChatResource.read(reader); this._rebuildTabs(chats, activeChatUri, mainChatUri); - - // The trailing "New Chat" action only applies to sessions that support - // user-created peer chats. Subagent (read-only) tabs can surface in - // sessions without that capability, so gate the action on the - // capability rather than on tab-strip visibility. const supportsMultipleChats = delegate.session.capabilities.read(reader).supportsMultipleChats; - this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats); - // Archived sessions are read-only, so disable the trailing New Chat - // action (mirrors the header action's SessionIsArchivedContext gating). - this._newChatAction.enabled = supportsMultipleChats && !delegate.session.isArchived.read(reader); + const isQuickChat = delegate.session.isQuickChat?.read(reader) ?? false; + this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats || isQuickChat); + this._newChatAction.enabled = supportsMultipleChats && !isQuickChat && !delegate.session.isArchived.read(reader); + this._showSessionActions = delegate.showSessionActions.read(reader); + this._sessionActionsContainer.classList.toggle('hidden', !this._showSessionActions); + this._updateMetaRowVisibility(); this._setVisible(delegate.visible.read(reader)); })); } + private _updateMetaRowVisibility(): void { + this._metaRow.style.display = this._showSessionActions && !this._metaToolbar.isEmpty() ? '' : 'none'; + } + setAriaLabel(label: string): void { this._tabsContainer.setAttribute('aria-label', label); } @@ -301,7 +340,9 @@ export class ChatCompositeBar extends Disposable { const labelEl = $('.chat-composite-bar-tab-label.modern-ui-editor-tab-label'); this._tabDisposables.add(autorun(reader => { const title = chat.title.read(reader); + const status = chat.status.read(reader); labelEl.textContent = title; + tab.setAttribute('aria-label', localize('chatTabAriaLabel', "{0}, {1}", title, getSessionConversationStatusAriaLabel(status))); })); // Lock icon shown for read-only (non-interactive) chats. diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index 500573c644963..d7307f3edb6a2 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -46,6 +46,9 @@ export interface IChatGroupContext { /** Whether the group's tab strip should be shown. */ readonly tabsVisible: IObservable; + /** Whether this group's tab row replaces the session header and shows its actions. */ + readonly showSessionActions: IObservable; + /** Activate (show + focus) the given chat within this group. */ openChat(resource: URI): void; @@ -170,6 +173,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { activeChatResource: context.activeChatResource, mainChatResource: context.mainChatResource, visible: context.tabsVisible, + showSessionActions: context.showSessionActions, openChat: resource => context.openChat(resource), newChat: () => context.newChat(), onTabDragStart: resource => context.onTabDragStart(resource), diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index 5ef6dcd2a0c7f..eb10414482596 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -280,6 +280,7 @@ export class ChatGroupsView extends Themable { } return session.shouldShowChatTabs.read(reader); }); + const showSessionActions = derived(reader => this._groupCount.read(reader) === 1 && tabsVisible.read(reader)); const view = store.add(this._instantiationService.createInstance(ChatGroupView)); const entry: IGroupEntry = { id, view, resourceIds, activeResourceId, chats, tabsVisible }; @@ -295,6 +296,7 @@ export class ChatGroupsView extends Themable { activeChatResource: activeResourceId, mainChatResource: this._mainChatResource!, tabsVisible, + showSessionActions, openChat: resource => this._openChat(entry, resource), newChat: () => this._newChat(entry).catch(onUnexpectedError), onTabDragStart: () => { }, diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 93943c6187a03..80f240ee0a10f 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -12,20 +12,15 @@ overflow: hidden; } -/* Header host: title row + meta row, with the top padding for the whole bar area */ +/* Header host: title row + meta row. */ .chat-composite-bar.session-header-bar { - padding: 6px 10px 0; + padding: 0 var(--vscode-spacing-size100); box-sizing: border-box; } -/* Tabs host: the chat tab strip, shown only when the session has multiple chats. - It lives in the same centered session-view content host as the header. - Symmetric 10px gutter on both sides, matching the small, even inset editor - tabs use — the tab strip no longer tries to align its first tab under the - header's status-icon column, since that produced a much larger left gutter - than the shared modern-tab convention. */ +/* Tabs host: the chat tab strip, shown only when the session has multiple chats. */ .chat-composite-bar.session-chat-tabs-bar { - padding: 0 10px; + padding: 0 var(--vscode-spacing-size100); box-sizing: border-box; container-type: inline-size; @@ -41,8 +36,7 @@ flex-direction: row; align-items: flex-start; gap: 6px; - padding-bottom: 6px; - border-bottom: 1px solid color-mix(in srgb, var(--session-view-foreground) 12%, transparent); + border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); } /* Main column stacks the title row and the meta row */ @@ -59,7 +53,7 @@ display: flex; align-items: center; gap: 6px; - height: 26px; + height: 34px; } /* Status icon column — sits beside the main column, centered on the title line. @@ -70,7 +64,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - height: 26px; + height: 34px; font-size: var(--vscode-codiconFontSize, 16px); color: var(--session-view-foreground); } @@ -81,7 +75,7 @@ overflow: hidden; display: flex; align-items: center; - font-weight: var(--vscode-agents-fontWeight-semiBold, 600); + font-weight: var(--vscode-agents-fontWeight-regular, 400); font-size: var(--vscode-agents-fontSize-heading3, 13px); color: var(--chat-tab-active-foreground, var(--session-view-foreground)); border-radius: var(--vscode-cornerRadius-small); @@ -100,45 +94,6 @@ white-space: nowrap; } -.chat-composite-bar-workspace-meta { - display: inline-flex; - align-items: center; - gap: var(--vscode-spacing-size40); - flex: 0 1 auto; - min-width: 0; - max-width: 40%; - color: var(--vscode-descriptionForeground); - font-size: var(--vscode-agents-fontSize-label1); - font-weight: var(--vscode-agents-fontWeight-regular); - white-space: nowrap; -} - -.chat-composite-bar-workspace-meta.hidden { - display: none; -} - -/* Compact glyph at the compact size. The compound selector outranks the base - `.codicon` font shorthand; the clamped box keeps combined glyphs (wider - advance) tight against the label, and the padding optically centers it. */ -.monaco-workbench .chat-composite-bar-workspace-meta-icon.codicon[class*='codicon-'] { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--vscode-codiconFontSize-compact); - height: var(--vscode-codiconFontSize-compact); - margin: 0; - padding: 3px 1px 0 2px; - font-size: var(--vscode-codiconFontSize-compact); - flex-shrink: 0; -} - -.chat-composite-bar-workspace-meta-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - /* Hover feedback: only when the title can actually be renamed and we aren't currently editing it. */ .chat-composite-bar-session-title.editable { @@ -227,9 +182,18 @@ display: flex; align-items: center; height: 35px; + box-sizing: border-box; overflow: hidden; } +.chat-groups-view.single-group .chat-composite-bar-tabs-row { + border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); +} + +:is(.hc-black, .hc-light) .chat-groups-view.single-group .chat-composite-bar-tabs-row { + border-bottom-color: var(--vscode-contrastBorder); +} + /* The ScrollableElement wrapper holding the tabs is the shrinkable flex item */ .chat-composite-bar-tabs-row > .monaco-scrollable-element { flex: 0 1 auto; @@ -237,35 +201,23 @@ height: 100%; } -.chat-composite-bar-tabs { +.chat-composite-bar-new-chat { display: flex; align-items: center; - height: 100%; - min-height: calc(var(--vscode-spacing-size240) + var(--vscode-spacing-size40) * 2); -} - -/* "New Chat" button pinned at the end of the tab strip, after the Conversations menu. */ -.chat-composite-bar-tabs-row > .chat-composite-bar-new-chat { flex-shrink: 0; - display: flex; - align-items: center; - margin-left: 4px; } -.chat-composite-bar-tabs-row > .chat-composite-bar-new-chat.hidden { +.chat-composite-bar-new-chat.hidden { display: none; } -/* Include the tab-row owner to outrank `.monaco-action-bar .action-item .codicon`, - * which otherwise resets this button's width and height from 26px to 16px. */ -.chat-composite-bar-tabs-row > .chat-composite-bar-new-chat .action-item .action-label { - box-sizing: border-box; - width: 26px; - height: 26px; - padding: 0; +.chat-composite-bar-new-chat .action-item .action-label { display: flex; align-items: center; justify-content: center; + width: var(--editor-group-tab-height, var(--vscode-spacing-size240)); + height: var(--editor-group-tab-height, var(--vscode-spacing-size240)); + padding: 0; border-radius: var(--vscode-cornerRadius-small); color: var(--chat-tab-inactive-foreground, currentColor); } @@ -276,8 +228,27 @@ } .chat-composite-bar-new-chat .action-item .action-label:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.session-chat-tabs-actions { + display: flex; + align-items: center; + margin-left: auto; + padding-right: var(--vscode-spacing-size40); + flex-shrink: 0; +} + +.session-chat-tabs-actions.hidden { + display: none; +} + +.chat-composite-bar-tabs { + display: flex; + align-items: center; + height: 100%; + min-height: calc(var(--vscode-spacing-size240) + var(--vscode-spacing-size40) * 2); } .chat-composite-bar-toolbar { diff --git a/src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts b/src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts deleted file mode 100644 index 73b02e784f152..0000000000000 --- a/src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts +++ /dev/null @@ -1,138 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IAction } from '../../../base/common/actions.js'; -import { IDisposable } from '../../../base/common/lifecycle.js'; -import { Codicon } from '../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; -import { localize } from '../../../nls.js'; -import { ActionWidgetDropdownActionViewItem } from '../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; -import { IMenuService, MenuItemAction, SubmenuItemAction } from '../../../platform/actions/common/actions.js'; -import { IActionWidgetService } from '../../../platform/actionWidget/browser/actionWidget.js'; -import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider } from '../../../platform/actionWidget/browser/actionWidgetDropdown.js'; -import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; -import { getSelectedSessionConversationActionId, getSessionConversationActionId, getSessionConversationStatusAriaLabel, getSessionConversationStatusDescription, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../sessionConversationGroups.js'; -import { Menus } from '../menus.js'; -import { ISessionContext } from '../../services/sessions/browser/sessionContext.js'; -import { ISessionsListModelService } from '../../services/sessions/browser/sessionsListModelService.js'; - -export interface ISessionConversationActionMetadata { - readonly description?: string; - readonly ariaDescription: string; - readonly icon: ThemeIcon; -} - -const sessionConversationGroups = [ - { id: SESSION_CONVERSATION_CHATS_GROUP, label: localize('sessionConversationGroup.chats', "Chats"), showHeader: false, order: 1 }, - { id: SESSION_CONVERSATION_SUBAGENTS_GROUP, label: localize('sessionConversationGroup.subagents', "Subagents"), showHeader: true, order: 2 }, -] as const; - -export function toSessionConversationDropdownActions( - menuActions: readonly (readonly [string, readonly IAction[]])[], - actionMetadata: ReadonlyMap = new Map(), -): IActionWidgetDropdownAction[] { - const groupsById = new Map(sessionConversationGroups.map(group => [group.id, group])); - const actionsByGroup = new Map(); - - for (const [groupId, actions] of menuActions) { - const group = groupsById.get(groupId); - const dropdownActions = actions.map(action => { - const metadata = actionMetadata.get(action.id); - return { - id: action.id, - label: action.label, - tooltip: action.tooltip, - description: metadata?.description, - ariaDescription: metadata?.ariaDescription, - icon: metadata?.icon, - class: action.class, - enabled: action.enabled, - category: { - label: group?.label ?? '', - order: group?.order ?? Number.MAX_SAFE_INTEGER, - showHeader: group?.showHeader ?? false, - }, - run: () => action.run(), - } satisfies IActionWidgetDropdownAction; - }); - actionsByGroup.set(groupId, dropdownActions); - } - - const chatActions = actionsByGroup.get(SESSION_CONVERSATION_CHATS_GROUP) ?? []; - const subagentActions = actionsByGroup.get(SESSION_CONVERSATION_SUBAGENTS_GROUP) ?? []; - if (chatActions.length === 1) { - return subagentActions; - } - - return sessionConversationGroups.flatMap(group => actionsByGroup.get(group.id) ?? []); -} - -/** Renders the scoped Conversations menu with the Sessions workbench Action Widget dropdown. */ -export class SessionConversationsActionViewItem extends ActionWidgetDropdownActionViewItem { - - constructor( - action: SubmenuItemAction, - @IActionWidgetService actionWidgetService: IActionWidgetService, - @IKeybindingService keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, - @IMenuService menuService: IMenuService, - @ISessionContext sessionContext: ISessionContext, - @ISessionsListModelService sessionsListModelService: ISessionsListModelService, - @ITelemetryService telemetryService: ITelemetryService, - ) { - const menu = menuService.createMenu(Menus.SessionConversations, contextKeyService); - const getSelectedChatActionId = () => { - const session = sessionContext.session.get(); - const activeChat = session?.activeChat.get(); - if (!session || !activeChat) { - return undefined; - } - return getSelectedSessionConversationActionId(session.sessionId, activeChat); - }; - const actionProvider: IActionWidgetDropdownActionProvider = { - getActions: () => { - const session = sessionContext.session.get(); - const actionMetadata = new Map(); - if (session) { - for (const chat of session.chats.get()) { - const actionId = getSessionConversationActionId(session.sessionId, chat.resource); - const status = chat.status.get(); - actionMetadata.set(actionId, { - description: getSessionConversationStatusDescription(status), - ariaDescription: getSessionConversationStatusAriaLabel(status), - icon: sessionsListModelService.getStatusIcon(status, chat.isRead.get(), chat.isArchived.get()), - }); - } - } - return toSessionConversationDropdownActions( - menu.getActions().map(([group, actions]) => [group, actions.filter(action => action instanceof MenuItemAction)] as const), - actionMetadata, - ); - }, - }; - - super(action, { - actionProvider, - getInitialFocusActionId: getSelectedChatActionId, - listOptions: { - hideDefaultKeybindingTooltip: true, - }, - reporter: { id: 'SessionConversations' }, - }, actionWidgetService, keybindingService, contextKeyService, telemetryService); - this._register(menu); - } - - protected override renderLabel(element: HTMLElement): IDisposable | null { - element.classList.add(...ThemeIcon.asClassNameArray(Codicon.commentDiscussion)); - return super.renderLabel(element); - } - - protected override setAriaLabelAttributes(element: HTMLElement): void { - super.setAriaLabelAttributes(element); - element.setAttribute('aria-label', this.action.label); - } -} diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index 3baf192e26a74..fcde5fa5b2272 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -6,7 +6,7 @@ import './media/chatCompositeBar.css'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent, reset } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent } from '../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; @@ -32,9 +32,6 @@ import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js' import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; -import { getSessionWorkspaceDisplayInfo } from '../sessionWorkspace.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; -import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; /** @@ -52,7 +49,6 @@ export class SessionHeader extends Disposable { private readonly _iconEl: HTMLElement; private readonly _titleEl: HTMLElement; private readonly _titleTextEl: HTMLElement; - private readonly _workspaceMetaEl: HTMLElement; private readonly _metaRow: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; private readonly _metaToolbar: MenuWorkbenchToolBar; @@ -62,6 +58,8 @@ export class SessionHeader extends Disposable { private readonly _editingDisposables = this._register(new MutableDisposable()); private _renameInput: HTMLInputElement | undefined; private _session: IActiveSession | undefined; + private _sessionIsCreated = false; + private _requestedVisible = true; // dragstart's own target is always the draggable container, so this tracks the // preceding pointerdown's target to know where the gesture actually began. @@ -79,7 +77,6 @@ export class SessionHeader extends Disposable { private readonly _metaActionsSignal: IObservable; private readonly _showMetadataInChatInput: IObservable; - private readonly _workspaceHover = this._register(new MutableDisposable()); private readonly _statusIcon: SessionStatusIcon; @@ -103,7 +100,6 @@ export class SessionHeader extends Disposable { @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, @IConfigurationService configurationService: IConfigurationService, - @IHoverService private readonly _hoverService: IHoverService, ) { super(); @@ -135,9 +131,6 @@ export class SessionHeader extends Disposable { this._titleTextEl = $('span.chat-composite-bar-session-title-text'); this._titleEl.appendChild(this._titleTextEl); - this._workspaceMetaEl = $('.chat-composite-bar-workspace-meta'); - titleRow.appendChild(this._workspaceMetaEl); - // Click the title to start an inline rename. Click is preferred over // mousedown so that initiating a drag from the title doesn't also // flip into edit mode. @@ -155,9 +148,6 @@ export class SessionHeader extends Disposable { hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { shouldForwardArgs: true }, highlightToggledItems: true, - // Render every group in the primary slot with a separator between groups - // so the actions stay visually grouped. - toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, })); this._metaRow = $('.chat-composite-bar-meta-row'); @@ -200,7 +190,7 @@ export class SessionHeader extends Disposable { })); this._register(heightObserver.observe(this._container)); - this._setVisible(false); + this._applyVisibility(false); this._updateStyles(); this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles())); @@ -293,7 +283,8 @@ export class SessionHeader extends Disposable { this._sessionDisposables.value = store; if (!session) { - this._setVisible(false); + this._sessionIsCreated = false; + this._updateVisibility(); return; } @@ -302,10 +293,23 @@ export class SessionHeader extends Disposable { })); store.add(autorun(reader => { - this._setVisible(session.isCreated.read(reader)); + this._sessionIsCreated = session.isCreated.read(reader); + this._updateVisibility(); })); } + setVisible(visible: boolean): void { + if (this._requestedVisible === visible) { + return; + } + this._requestedVisible = visible; + this._updateVisibility(); + } + + private _updateVisibility(): void { + this._applyVisibility(this._sessionIsCreated && this._requestedVisible); + } + private _updateHeader(session: IActiveSession, reader: IReader): void { // Session icon — the SessionStatusIcon widget owns the rendering (spinner vs. // codicon, cross-fade, reduced-motion); here we just feed it the latest state. @@ -321,21 +325,6 @@ export class SessionHeader extends Disposable { this._titleTextEl.textContent = session.title.read(reader) || getUntitledSessionTitle(isQuickChat); this._titleEl.classList.toggle('editable', this._isTitleEditable()); const showMetadataInChatInput = this._showMetadataInChatInput.read(reader); - const workspaceInfo = showMetadataInChatInput && !isQuickChat ? getSessionWorkspaceDisplayInfo(session, reader) : undefined; - this._workspaceMetaEl.classList.toggle('hidden', !workspaceInfo); - this._workspaceHover.clear(); - if (workspaceInfo) { - const label = $('span.chat-composite-bar-workspace-meta-label', undefined, workspaceInfo.label); - reset( - this._workspaceMetaEl, - $('span.chat-composite-bar-workspace-meta-separator', { 'aria-hidden': 'true' }, '·'), - $(`span.chat-composite-bar-workspace-meta-icon${ThemeIcon.asCSSSelector(workspaceInfo.icon)}`, { 'aria-hidden': 'true' }), - label, - ); - this._workspaceHover.value = this._hoverService.setupDelayedHover(label, { content: workspaceInfo.label }); - } else { - reset(this._workspaceMetaEl); - } // Meta row: contributed action pills (workspace folder · diff stats · pull request). // Reading the signal re-runs this on menu changes. @@ -346,7 +335,7 @@ export class SessionHeader extends Disposable { this._onDidChangeHeight.fire(); } - private _setVisible(visible: boolean): void { + private _applyVisibility(visible: boolean): void { const wasVisible = this._visible; this._visible = visible; this._container.style.display = this._visible ? '' : 'none'; @@ -503,7 +492,6 @@ export class SessionViewFloatingToolbar extends Disposable { hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { shouldForwardArgs: true }, highlightToggledItems: true, - toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, })); this._setVisible(false); diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 38a35ed78a374..ff65256a864c9 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -148,6 +148,14 @@ export class SessionView extends Disposable implements ISerializableView { this.element.classList.toggle('grid-layout', isGridLayout); this._layoutChildren(); })); + + this._register(autorun(reader => { + const session = this._sessionObs.read(reader); + const tabsReplaceHeader = this._groupsView.groupCount.read(reader) === 1 + && (session?.isCreated.read(reader) ?? false) + && (session?.shouldShowChatTabs.read(reader) ?? false); + this._header.setVisible(!tabsReplaceHeader); + })); } openSession(session: IActiveSession | undefined, options: ISessionViewOptions): void { diff --git a/src/vs/sessions/browser/sessionConversationGroups.ts b/src/vs/sessions/browser/sessionConversationGroups.ts index b17af138724b9..4c16c3881a715 100644 --- a/src/vs/sessions/browser/sessionConversationGroups.ts +++ b/src/vs/sessions/browser/sessionConversationGroups.ts @@ -16,10 +16,6 @@ export function getSessionConversationActionId(sessionId: string, chatResource: return `sessions.openChat.${sessionId}.${hash(chatResource.toString())}`; } -export function getSelectedSessionConversationActionId(sessionId: string, activeChat: IChat): string { - return getSessionConversationActionId(sessionId, activeChat.resource); -} - export function getSessionConversationStatusLabel(status: SessionStatus): string { switch (status) { case SessionStatus.Untitled: @@ -39,10 +35,6 @@ export function getSessionConversationStatusAriaLabel(status: SessionStatus): st return localize('sessionConversationStatus.ariaLabel', "State: {0}", getSessionConversationStatusLabel(status)); } -export function getSessionConversationStatusDescription(status: SessionStatus): string | undefined { - return status === SessionStatus.Completed ? undefined : getSessionConversationStatusLabel(status); -} - /** Returns the contributed menu group for a chat in the scoped session. */ export function getSessionConversationGroupId(chat: IChat, activeChat: IChat, extUri: IExtUri): string | undefined { if (chat.origin?.kind === ChatOriginKind.Tool) { diff --git a/src/vs/sessions/browser/sessionWorkspace.ts b/src/vs/sessions/browser/sessionWorkspace.ts index a23cc17e24c50..d32c7e77415fa 100644 --- a/src/vs/sessions/browser/sessionWorkspace.ts +++ b/src/vs/sessions/browser/sessionWorkspace.ts @@ -16,7 +16,7 @@ export interface ISessionWorkspaceDisplayInfo { readonly worktreePending: boolean; } -/** Returns the workspace presentation shared by the session header and Files pill. */ +/** Returns the workspace presentation shared by the command center and Files pill. */ export function getSessionWorkspaceDisplayInfo(session: ISession | undefined, reader: IReader): ISessionWorkspaceDisplayInfo | undefined { const workspace = session?.workspace.read(reader); if (!workspace?.label) { diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 157073097f5ac..eb15ba98562ae 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -32,7 +32,7 @@ export const SessionSupportsForkContext = new RawContextKey('sessionSup export const SessionSupportsSideChatContext = new RawContextKey('sessionSupportsSideChat', false, localize('sessionSupportsSideChat', "Whether the session view's session supports creating a side chat from a turn (via /btw)")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Chats dropdown visibility")); export const SessionActiveChatHasSubagentsContext = new RawContextKey('sessionActiveChatHasSubagents', false, localize('sessionActiveChatHasSubagents', "Whether the active chat has subagents, which are shown in the Chats dropdown")); -export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip. Used to hide the header New Chat button, which the tab strip then offers instead")); +export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat (the tabs shown in the strip, including in-composer drafts). Used to scope chat-to-chat navigation (next/previous chat, the Ctrl+Tab chat switcher)")); export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed (hidden) from the tab strip, i.e. it is not the main chat. Includes read-only subagent chats. Used to scope the close-chat keybinding so it closes the tab instead of the session")); export const SessionActiveChatIsDeletableContext = new RawContextKey('sessionActiveChatIsDeletable', false, localize('sessionActiveChatIsDeletable', "Whether the session's active chat can be permanently deleted from the tab strip, i.e. it is a real, user-created non-main chat (not the main chat and not a tool-spawned subagent chat, which are transient children). Used to scope the delete-chat keybinding")); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 557d711fc050a..0b37589a225e2 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -46,7 +46,7 @@ /* Session pill - clickable area for session picker, fills the command center box */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill { display: flex; - justify-content: space-around; + justify-content: flex-start; align-items: center; flex: 1 1 auto; padding: 0 8px; @@ -64,54 +64,41 @@ outline-offset: -1px; } -/* Center group: icon + label + folder */ +/* Center group: workspace icon and folder */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-center { display: flex; align-items: center; - gap: 6px; + gap: 0; + height: 100%; min-width: 0; justify-content: flex-start; cursor: pointer; overflow: hidden; } -/* Codicons use the base 16px token rather than an unsupported intermediate size. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-icon { +/* Workspace name shown beside its icon. */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-workspace { display: flex; align-items: center; - flex-shrink: 0; - font-size: var(--vscode-codiconFontSize); -} - -/* Session title - primary label in the command center box. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-title { flex: 0 1 auto; + height: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* Workspace name - secondary, dimmed label shown after the session title. - It must not be cropped: when space is tight the session title truncates - first while the workspace name stays fully visible. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-workspace { - flex: 0 0 auto; - white-space: nowrap; +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-workspace-icon { + display: flex; + align-items: center; + flex-shrink: 0; + height: 100%; + font-size: var(--vscode-codiconFontSize-compact); } .command-center > .monaco-toolbar > .monaco-action-bar > .actions-container > .action-item:not(.disabled) > .action-label { color: var(--vscode-icon-foreground); } -/* Separator between the session title and the workspace name. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-separator { - flex-shrink: 0; - - &::before { - content: '\00B7'; - } -} - /* Sidebar toggle unread badge */ .agent-sessions-workbench .action-item.sidebar-toggle-action { position: relative; diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 6a0bd7123b316..38a00abdd3d8a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -13,7 +13,7 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { SessionsTelemetryContribution } from './sessionsTelemetry.contribution.js'; -import { NewSessionActionViewItemContribution, SessionConversationsActionViewItemContribution, SessionConversationsMenuContribution, SessionNewChatActionViewItemContribution } from './sessionsActions.js'; +import { NewSessionActionViewItemContribution, SessionConversationActionsContribution } from './sessionsActions.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import { AutomationsCustomViewContribution } from './views/automationsView.js'; import './views/sessionsViewActions.js'; @@ -75,9 +75,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis registerWorkbenchContribution2(AutomationsCustomViewContribution.ID, AutomationsCustomViewContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(NewSessionActionViewItemContribution.ID, NewSessionActionViewItemContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(SessionConversationsActionViewItemContribution.ID, SessionConversationsActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsMouseNavigationContribution.ID, SessionsMouseNavigationContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTelemetryContribution.ID, SessionsTelemetryContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionsWindowNotifier.ID, SessionsWindowNotifier, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(SessionConversationsMenuContribution.ID, SessionConversationsMenuContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(SessionNewChatActionViewItemContribution.ID, SessionNewChatActionViewItemContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionConversationActionsContribution.ID, SessionConversationActionsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 7f02a91e7567d..45b14b6cb78be 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -11,11 +11,11 @@ import { autorun, IReader, observableSignalFromEvent } from '../../../../base/co import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; -import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InputFocusedContext } from '../../../../platform/contextkey/common/contextkeys.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; @@ -26,17 +26,16 @@ import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/l import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { CLOSE_CHAT_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { $, append, EventHelper, ModifierKeyEmitter, reset } from '../../../../base/browser/dom.js'; -import { BaseActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; @@ -54,7 +53,6 @@ import { logSessionsInteraction, SessionsInteractionSource } from '../../../comm import { NEW_SESSION_ACTION_ID } from '../../chat/common/constants.js'; import { groupSessionsForPicker } from './sessionsPicker.js'; import { getSessionConversationActionId, getSessionConversationGroupId } from '../../../browser/sessionConversationGroups.js'; -import { SessionConversationsActionViewItem } from '../../../browser/parts/sessionConversationsActionViewItem.js'; import './media/newSessionActionViewItem.css'; // -- Show Sessions Picker -- @@ -532,44 +530,50 @@ registerAction2(class CloseAllSessionsAction extends Action2 { // session-level commands when the tab strip is not shown. const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; -// "New Chat" starts a new chat. Hidden once the session has more than one open -// chat, since the chat tab strip then offers New Chat at the end of the tabs. +// "New Chat in This Session" starts a new chat from the session header's overflow menu. const ADD_CHAT_TO_SESSION_ACTION_ID = 'sessions.chatCompositeBar.addChat'; registerAction2(class AddChatToSessionAction extends Action2 { constructor() { super({ id: ADD_CHAT_TO_SESSION_ACTION_ID, - title: localize2('chatCompositeBar.addChat', "New Chat"), + title: localize2('chatCompositeBar.addChat', "New Chat in This Session"), icon: Codicon.add, keybinding: { weight: CHAT_TAB_KEYBINDING_WEIGHT, // Like Cmd/Ctrl+T in a browser — opens a new chat tab within the // active session. Scoped so it does not steal the shortcut outside // the agents window or when the session does not support multiple chats. - when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionIsCreatedContext, SessionSupportsMultipleChatsContext, IsQuickChatSessionContext.negate(), SessionIsArchivedContext.negate()), primary: KeyMod.CtrlCmd | KeyCode.KeyT, }, - menu: { + menu: [{ id: Menus.SessionBarToolbar, - group: 'navigation', + group: 'secondary/2_chats', + order: 20, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, IsQuickChatSessionContext.negate(), SessionIsArchivedContext.negate()), + }, { + id: Menus.SessionItemContextMenu, + group: '1_newChat', order: 0, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionShouldShowChatTabsContext.negate()), - }, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, IsQuickChatSessionContext.negate(), SessionIsArchivedContext.negate()), + }], }); } - override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise { + override async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { const sessionsService = accessor.get(ISessionsService); const sessionsPartService = accessor.get(ISessionsPartService); - // From the menu: session is forwarded as context. From the keybinding: - // fall back to the active session. - const target = session ?? sessionsService.activeSession.get(); + const target = Array.isArray(context) ? context[0] : context ?? sessionsService.activeSession.get(); if (!target) { return; } + if (target.isQuickChat?.get()) { + return; + } await sessionsService.openNewChatInSession(target); - sessionsPartService.focusSession(target); + const activeSession = sessionsService.activeSession.get(); + sessionsPartService.focusSession(activeSession?.sessionId === target.sessionId ? activeSession : undefined); } }); @@ -1265,125 +1269,13 @@ export class NewSessionActionViewItemContribution extends Disposable implements } /** - * Renders the "New Chat" action in the session header as the compact pill, matching the - * "New" session pill in the sessions list header / titlebar. - */ -class NewChatActionViewItem extends CompactButtonActionViewItem { - - protected override get commandId(): string { - return ADD_CHAT_TO_SESSION_ACTION_ID; - } - - protected override get label(): string { - return localize('chatCompositeBar.addChat.compact', "New Chat"); - } - - protected override get showKeybindingHint(): boolean { - return false; - } - - protected override getHoverContent(keybindingLabel: string | undefined): string { - return keybindingLabel - ? localize('newChatButtonTitle', "New Chat ({0})", keybindingLabel) - : localize('newChatButtonTitleWithoutKeybinding', "New Chat"); - } - - protected override getAriaLabel(keybindingAriaLabel: string | undefined): string { - return keybindingAriaLabel - ? localize('newChatButtonAriaLabel', "New Chat ({0})", keybindingAriaLabel) - : localize('newChatButtonAriaLabelWithoutKeybinding', "New Chat"); - } -} - -export class SessionNewChatActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.sessions.newChatActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Fire once after registering so a header toolbar that was already built - // (e.g. for a session restored before this contribution runs) re-renders and - // picks up this factory; otherwise New Chat stays icon-only until its menu - // next changes. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionBarToolbar, ADD_CHAT_TO_SESSION_ACTION_ID, (action, _options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(NewChatActionViewItem, action); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - -export class SessionConversationsActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.sessions.conversationsActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - const provider = (action: IAction, _options: IActionViewItemOptions, instantiationService: IInstantiationService) => { - if (!(action instanceof SubmenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(SessionConversationsActionViewItem, action); - }; - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, Menus.SessionConversations, provider)); - this._register(actionViewItemService.register(Menus.SessionBarToolbar, Menus.SessionConversations, provider)); - } -} - -// The "Chats" toolbar entry is backed by a submenu whose groups are rendered by -// the Sessions workbench as an Action Widget dropdown. Selecting an entry opens -// or focuses that chat. -// -// It renders after the metadata pills by default, or after New Chat in the title -// toolbar when session metadata is configured to appear above the input. -MenuRegistry.appendMenuItem(Menus.SessionHeaderMeta, { - submenu: Menus.SessionConversations, - title: localize2('chatCompositeBar.conversations', "Chats"), - icon: Codicon.commentDiscussion, - group: 'navigation', - order: 100, - when: ContextKeyExpr.and( - SessionIsCreatedContext, - SessionIsArchivedContext.negate(), - ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), - ContextKeyExpr.notEquals(`config.${SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING}`, true), - ), -}); - -MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { - submenu: Menus.SessionConversations, - title: localize2('chatCompositeBar.conversations', "Chats"), - icon: Codicon.commentDiscussion, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and( - SessionIsCreatedContext, - SessionIsArchivedContext.negate(), - ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), - ContextKeyExpr.equals(`config.${SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING}`, true), - ), -}); - -/** - * Populates the {@link Menus.SessionConversations} menu for every visible - * session. {@link Menus.SessionBarToolbar} is rendered once per session view - * (header/floating toolbar) against that view's scoped context key service, so - * the menu items are scoped per session via {@link SessionIdContext}: each - * session's per-chat navigation actions only render in (and act on) their own - * session's toolbar. The actions are (re)registered whenever the set of visible - * sessions or their chat lists change. + * Populates the Chats submenu for each visible session. Actions are scoped per + * session via {@link SessionIdContext} and re-registered whenever visible + * sessions or their chats change. */ -export class SessionConversationsMenuContribution extends Disposable implements IWorkbenchContribution { +export class SessionConversationActionsContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.sessions.conversationsMenu'; + static readonly ID = 'workbench.contrib.sessions.conversationActions'; constructor( @ISessionsService private readonly _sessionsService: ISessionsService, @@ -1393,13 +1285,13 @@ export class SessionConversationsMenuContribution extends Disposable implements this._register(autorun(reader => { for (const session of this._sessionsService.visibleSessions.read(reader)) { if (session) { - reader.store.add(this._registerSessionConversations(session, reader)); + reader.store.add(this._registerSessionConversationActions(session, reader)); } } })); } - private _registerSessionConversations(session: IActiveSession, reader: IReader): IDisposable { + private _registerSessionConversationActions(session: IActiveSession, reader: IReader): IDisposable { const store = new DisposableStore(); const that = this; const extUri = this._uriIdentityService.extUri; @@ -1408,6 +1300,12 @@ export class SessionConversationsMenuContribution extends Disposable implements // per session view against its own scoped context key service, where // `sessionId` resolves to that view's session. const scopedToSession = ContextKeyExpr.equals(SessionIdContext.key, session.sessionId); + const conversationsVisible = ContextKeyExpr.and( + scopedToSession, + SessionIsCreatedContext, + SessionIsArchivedContext.negate(), + ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), + ); const allChats = session.chats.read(reader); const activeChat = session.activeChat.read(reader); @@ -1423,7 +1321,7 @@ export class SessionConversationsMenuContribution extends Disposable implements super({ id: getSessionConversationActionId(session.sessionId, chatResource), title, - menu: { id: Menus.SessionConversations, group, order, when: scopedToSession }, + menu: { id: Menus.SessionConversations, group, order, when: conversationsVisible }, }); } override async run(accessor: ServicesAccessor, forwardedSession?: IActiveSession): Promise { @@ -1464,20 +1362,33 @@ export class SessionConversationsMenuContribution extends Disposable implements } } +MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { + submenu: Menus.SessionConversations, + title: localize2('chatCompositeBar.conversations', "Chats"), + icon: Codicon.commentDiscussion, + group: 'secondary/2_chats', + order: 10, + when: ContextKeyExpr.and( + SessionIsCreatedContext, + SessionIsArchivedContext.negate(), + ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), + ), +}); + registerAction2(class TogglePinSessionAction extends Action2 { constructor() { super({ id: 'sessions.chatCompositeBar.togglePin', - title: localize2('chatCompositeBar.pin', "Pin Session"), + title: localize2('chatCompositeBar.pin', "Pin Session View"), icon: Codicon.pin, toggled: { condition: SessionIsStickyContext, icon: Codicon.pinned, - title: localize('chatCompositeBar.unpin', "Unpin Session"), + title: localize('chatCompositeBar.unpin', "Unpin Session View"), }, menu: { id: Menus.SessionBarToolbar, - group: '1_session', + group: 'secondary/3_pin', order: 10, when: ContextKeyExpr.and(SessionIsCreatedContext, SessionIsArchivedContext.negate()), }, @@ -1532,12 +1443,12 @@ registerAction2(class CloseSessionAction extends Action2 { constructor() { super({ id: 'sessions.chatCompositeBar.close', - title: localize2('chatCompositeBar.close', "Close"), + title: localize2('chatCompositeBar.close', "Close Session View"), icon: Codicon.close, menu: [{ id: Menus.SessionBarToolbar, when: ContextKeyExpr.or(SessionIsCreatedContext, MultipleSessionsVisibleContext), - group: '1_session', + group: 'secondary/3_pin', order: 30, }, { id: Menus.SessionHeaderContext, @@ -1561,17 +1472,17 @@ registerAction2(class ToggleMaximizeSessionViewAction extends Action2 { constructor() { super({ id: 'sessions.chatCompositeBar.toggleMaximize', - title: localize2('chatCompositeBar.maximize', "Maximize Session"), + title: localize2('chatCompositeBar.maximize', "Maximize Session View"), icon: Codicon.screenFull, toggled: { condition: SessionIsMaximizedContext, icon: Codicon.screenNormal, - title: localize('chatCompositeBar.unmaximize', "Restore Session"), + title: localize('chatCompositeBar.unmaximize', "Restore Session View"), }, menu: { id: Menus.SessionBarToolbar, when: MultipleSessionsVisibleContext, - group: '1_session', + group: 'secondary/3_pin', order: 20, }, }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index af7da5966f8b9..94dcfe9da6c03 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -32,7 +32,6 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { getUntitledSessionTitle } from '../../../services/sessions/common/session.js'; import { BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; import { BlockedSessionsList, IBlockedSessionsHeaderActionContext, registerBlockedSessionsItemActions } from './blockedSessionsList.js'; import { BlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; @@ -40,6 +39,8 @@ import { SessionActionFeedback } from './sessionActionFeedback.js'; import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessionsIndicatorModel.js'; import { openSessionToTheSide } from './views/sessionsView.js'; +import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; /** * Internal command behind the blocked-sessions dropdown header's "Show All @@ -127,9 +128,8 @@ const BLOCKED_DROPDOWN_MAX_WIDTH_RATIO = 0.9; * Sessions Title Bar Widget - renders the active chat session * in the command center of the agent sessions workbench. * - * Shows the current chat session as a clickable pill with: - * - Kind icon at the beginning (provider type icon) - * - Repository folder name and active branch/worktree name when available + * Shows the current chat session as a clickable pill with its workspace icon + * and folder name when available. * * When at least one session is blocked (needs input or has failing CI checks), * the widget instead adopts an orange "N sessions require input" state and reveals those sessions as a @@ -157,6 +157,8 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { /** Guard to prevent re-entrant rendering */ private _isRendering = false; + private _workspaceInfo: ISessionWorkspaceDisplayInfo | undefined; + private _isQuickChat = false; /** Model behind the "N sessions require input" indicator (blocked-session set, blink, labels). */ private readonly _blockedIndicator: BlockedSessionsIndicatorModel; @@ -188,6 +190,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IHoverService private readonly hoverService: IHoverService, ) { super(undefined, action, options); @@ -215,11 +218,8 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // Re-render when the active session's title, workspace, or quick-chat kind changes this._register(autorun(reader => { const sessionData = this.sessionsService.activeSession.read(reader); - if (sessionData) { - sessionData.title.read(reader); - sessionData.workspace.read(reader); - sessionData.isQuickChat?.read(reader); - } + this._workspaceInfo = getSessionWorkspaceDisplayInfo(sessionData, reader); + this._isQuickChat = sessionData?.isQuickChat?.read(reader) ?? false; this._lastRenderState = undefined; this._render(); })); @@ -308,10 +308,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } else if (showRequiresInput) { renderState = `blocked|${blockedCount}|${requiresInputKind ?? 'mixed'}`; } else { - const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); - const workspaceLabel = this._getRepositoryLabel(); - renderState = `normal|${icon?.id ?? ''}|${sessionTitle ?? ''}|${workspaceLabel ?? ''}`; + renderState = `normal|${this._workspaceInfo?.icon.id ?? ''}|${this._workspaceInfo?.label ?? ''}|${this._isQuickChat}`; } // Skip re-render if state hasn't changed @@ -362,43 +359,35 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } /** - * Render the active-session pill: icon + title + workspace. Clicking opens the + * Render the active-session pill: workspace icon + folder. Clicking opens the * sessions picker. */ private _renderActiveSession(): void { const container = this._container!; container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); - const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); - const workspaceLabel = this._getRepositoryLabel(); + const workspaceInfo = this._workspaceInfo; - // Session pill: icon + title + workspace together + // Session pill: workspace icon + label const sessionPill = $('div.agent-sessions-titlebar-pill'); - // Center group: icon + title + workspace name + // Center group: workspace icon and name const centerGroup = $('div.agent-sessions-titlebar-center'); - // Kind icon at the beginning - if (icon) { - const iconEl = $('div.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); - centerGroup.appendChild(iconEl); - } - - // Session title shown next to the icon - if (sessionTitle) { - const titleEl = $('div.agent-sessions-titlebar-title'); - titleEl.textContent = sessionTitle; - centerGroup.appendChild(titleEl); - } + if (workspaceInfo) { + const workspaceIconEl = $(`div.agent-sessions-titlebar-workspace-icon${ThemeIcon.asCSSSelector(workspaceInfo.icon)}`, { 'aria-hidden': 'true' }); + centerGroup.appendChild(workspaceIconEl); - // Workspace name shown after the session title - if (workspaceLabel) { - const separatorEl = $('div.agent-sessions-titlebar-separator'); - centerGroup.appendChild(separatorEl); + const workspaceEl = $('div.agent-sessions-titlebar-workspace'); + workspaceEl.textContent = workspaceInfo.label; + centerGroup.appendChild(workspaceEl); + this._dynamicDisposables.add(this.hoverService.setupDelayedHover(workspaceEl, { content: workspaceInfo.label })); + } else if (this._isQuickChat) { + const workspaceIconEl = $(`div.agent-sessions-titlebar-workspace-icon${ThemeIcon.asCSSSelector(Codicon.commentDiscussion)}`, { 'aria-hidden': 'true' }); + centerGroup.appendChild(workspaceIconEl); const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = workspaceLabel; + workspaceEl.textContent = localize('noWorkspace', "No workspace"); centerGroup.appendChild(workspaceEl); } @@ -685,39 +674,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this.sessionsService.openSession(resource, { preserveFocus }).catch(onUnexpectedError); } - /** - * Get the icon for the active session's type. - */ - private _getActiveSessionIcon(): ThemeIcon | undefined { - const sessionData = this.sessionsService.activeSession.get(); - if (sessionData) { - return sessionData.icon; - } - return undefined; - } - - /** - * Get the display title for the active session. - */ - private _getSessionTitle(): string | undefined { - const sessionData = this.sessionsService.activeSession.get(); - return sessionData?.title.get()?.trim() || undefined; - } - - /** - * Get the repository label for the active session. - */ - private _getRepositoryLabel(): string | undefined { - const sessionData = this.sessionsService.activeSession.get(); - if (sessionData) { - const workspace = sessionData.workspace.get(); - if (workspace) { - return workspace.label; - } - } - return undefined; - } - private _showSessionsPicker(): void { this.commandService.executeCommand(SHOW_SESSIONS_PICKER_COMMAND_ID); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index e3d5e8e40a77e..b22a8d4c953c1 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -880,8 +880,8 @@ abstract class BaseArchiveSessionAction extends Action2 { when: ContextKeyExpr.equals(SessionIsArchivedContext.key, false), }, { id: Menus.SessionBarToolbar, - group: '1_session', - order: 5, + group: 'secondary/1_session', + order: 30, when: ContextKeyExpr.and(SessionIsCreatedContext, ContextKeyExpr.equals(SessionIsArchivedContext.key, false)), }] }); @@ -929,7 +929,7 @@ abstract class BaseUnarchiveSessionAction extends Action2 { when: ContextKeyExpr.equals(SessionIsArchivedContext.key, true), }, { id: Menus.SessionBarToolbar, - group: 'navigation', + group: 'secondary/1_session', order: 5, when: ContextKeyExpr.equals(SessionIsArchivedContext.key, true), }] @@ -969,11 +969,17 @@ registerAction2(class RenameSessionAction extends Action2 { super({ id: RENAME_SESSION_COMMAND_ID, title: localize2('renameSession', "Rename..."), + icon: Codicon.edit, menu: [{ id: SessionItemContextMenuId, group: '1_edit', order: 1, when: SessionSupportsRenameContext, + }, { + id: Menus.SessionBarToolbar, + group: 'secondary/1_session', + order: 20, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsRenameContext, SessionIsArchivedContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts new file mode 100644 index 0000000000000..5ded37d19d0d9 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, isISubmenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { Menus } from '../../../../browser/menus.js'; + +import '../../browser/sessionsActions.js'; +import '../../browser/views/sessionsViewActions.js'; + +suite('Sessions - Actions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('contributes New Chat to the session header overflow', () => { + const action = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessions.chatCompositeBar.addChat'); + + assert.deepStrictEqual({ + title: action && (typeof action.command.title === 'string' ? action.command.title : action.command.title.value), + group: action?.group, + order: action?.order, + when: action?.when?.serialize(), + }, { + title: 'New Chat in This Session', + group: 'secondary/2_chats', + order: 20, + when: 'sessionIsCreated && sessionSupportsMultipleChats && !isQuickChatSession && !sessionIsArchived', + }); + }); + + test('contributes New Chat to the session list item menu', () => { + const action = MenuRegistry.getMenuItems(Menus.SessionItemContextMenu) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessions.chatCompositeBar.addChat'); + + assert.deepStrictEqual({ + title: action && (typeof action.command.title === 'string' ? action.command.title : action.command.title.value), + group: action?.group, + order: action?.order, + when: action?.when?.serialize(), + }, { + title: 'New Chat in This Session', + group: '1_newChat', + order: 0, + when: 'sessionIsCreated && sessionSupportsMultipleChats && !isQuickChatSession && !sessionIsArchived', + }); + }); + + test('groups session management actions before creation and close', () => { + const actions = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .filter(item => item.command.id === 'sessions.chatCompositeBar.togglePin' || item.command.id === 'sessionsViewPane.renameSession' || item.command.id === 'sessions.chatCompositeBar.addChat' || item.command.id === 'sessions.chatCompositeBar.close') + .sort((a, b) => (a.group ?? '').localeCompare(b.group ?? '') || (a.order ?? 0) - (b.order ?? 0)) + .map(item => ({ id: item.command.id, group: item.group })); + + assert.deepStrictEqual(actions, [ + { id: 'sessionsViewPane.renameSession', group: 'secondary/1_session' }, + { id: 'sessions.chatCompositeBar.addChat', group: 'secondary/2_chats' }, + { id: 'sessions.chatCompositeBar.togglePin', group: 'secondary/3_pin' }, + { id: 'sessions.chatCompositeBar.close', group: 'secondary/3_pin' }, + ]); + }); + + test('contributes Chats before New Chat in the same overflow group', () => { + const chats = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isISubmenuItem) + .find(item => item.submenu === Menus.SessionConversations); + + assert.deepStrictEqual({ + group: chats?.group, + order: chats?.order, + }, { + group: 'secondary/2_chats', + order: 10, + }); + }); + + test('distinguishes pinning the session view from pinning the session in the list', () => { + const pin = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessions.chatCompositeBar.togglePin'); + + assert.strictEqual(pin && (typeof pin.command.title === 'string' ? pin.command.title : pin.command.title.value), 'Pin Session View'); + }); + + test('groups session view actions with consistent titles', () => { + const actions = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .filter(item => ['sessions.chatCompositeBar.togglePin', 'sessions.chatCompositeBar.toggleMaximize', 'sessions.chatCompositeBar.close'].includes(item.command.id)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(item => ({ + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + group: item.group, + })); + + assert.deepStrictEqual(actions, [ + { title: 'Pin Session View', group: 'secondary/3_pin' }, + { title: 'Maximize Session View', group: 'secondary/3_pin' }, + { title: 'Close Session View', group: 'secondary/3_pin' }, + ]); + }); +}); diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 870429bba093c..76285864761a2 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -196,8 +196,7 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, keys.hasMultipleCommittedChats.set(committedChatCount > 1); // The tab strip is shown when the session has more than one chat (counting - // closed chats) or its single remaining chat's title diverged from the - // session title; the header then hides its own New Chat button. + // closed chats) or its single remaining chat's title diverged from the session title. keys.shouldShowChatTabs.set(session?.shouldShowChatTabs.read(reader) ?? false); // More than one open chat tab (incl. drafts): scopes chat-to-chat navigation diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index eaffe9687f348..d0e63cafca2e7 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -53,7 +53,7 @@ function createChat(id: string, title: string, status: SessionStatus = SessionSt }(); } -function createSession(chats: readonly IChat[], activeChat: IChat): IActiveSession { +function createSession(chats: readonly IChat[], activeChat: IChat, isQuickChat = false): IActiveSession { const resource = URI.parse('test-session://session'); return new class extends mock() { override readonly sessionId = 'session'; @@ -69,6 +69,7 @@ function createSession(chats: readonly IChat[], activeChat: IChat): IActiveSessi override readonly capabilities: IObservable = constObservable({ supportsMultipleChats: true }); override readonly isCreated: IObservable = constObservable(true); override readonly isArchived: IObservable = constObservable(false); + override readonly isQuickChat: IObservable = constObservable(isQuickChat); }(); } @@ -82,14 +83,14 @@ interface IChatCompositeBarHarness { readonly tabs: readonly HTMLElement[]; } -function createHarness(disposables: Pick): IChatCompositeBarHarness { +function createHarness(disposables: Pick, isQuickChat = false): IChatCompositeBarHarness { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const commandService = new TestCommandService(); const sessionsService = new TestSessionsService(); const mainChat = createChat('main', 'Main Chat'); const secondaryChat = createChat('secondary', 'Secondary Chat'); - const session = createSession([mainChat, secondaryChat], mainChat); + const session = createSession([mainChat, secondaryChat], mainChat, isQuickChat); instantiationService.stub(ICommandService, commandService); instantiationService.stub(ISessionsService, sessionsService); @@ -109,6 +110,7 @@ function createHarness(disposables: Pick): IChatComposit activeChatResource: constObservable(session.activeChat.get().resource.toString()), mainChatResource: constObservable(session.mainChat.get().resource.toString()), visible: session.shouldShowChatTabs, + showSessionActions: session.shouldShowChatTabs, openChat: resource => { sessionsService.openChat(session, resource); }, newChat: () => { }, }; @@ -132,15 +134,22 @@ suite('Sessions - ChatCompositeBar', () => { hasFill: tab.querySelector(':scope > .chat-composite-bar-tab-fill.modern-ui-editor-tab-fill') !== null, hasLabel: tab.querySelector(':scope > .chat-composite-bar-tab-label.modern-ui-editor-tab-label') !== null, hasActions: tab.querySelector(':scope > .chat-composite-bar-tab-actions') !== null, + ariaLabel: tab.getAttribute('aria-label'), })), }, { tabs: [ - { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: false }, - { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: true }, + { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: false, ariaLabel: 'Main Chat, State: Completed' }, + { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: true, ariaLabel: 'Secondary Chat, State: Completed' }, ], }); }); + test('hides New Chat for workspace-less sessions', () => { + const { bar } = createHarness(disposables, true); + + assert.strictEqual(bar.element.querySelector('.chat-composite-bar-new-chat')?.classList.contains('hidden'), true); + }); + test('middle-click closes the targeted inactive non-main chat', () => { const { store, commandService, sessionsService, bar, session, tabs } = createHarness(disposables); let bubbled = 0; diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 1638a2f14a5f0..e73a2960f8826 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -402,20 +402,6 @@ suite('Sessions - ChatGroupsView', () => { }); }); - test('new chat action focuses its group composer', async () => { - const { view } = createHarness(disposables); - const main = createChat('main'); - const session = new TestActiveSession([main]); - view.setSession(session, options); - const group = view.element.querySelector('.chat-group-view')!; - - group.querySelector('.chat-composite-bar-new-chat .action-label')!.click(); - await Promise.resolve(); - await Promise.resolve(); - - assert.strictEqual(group.contains(mainWindow.document.activeElement), true); - }); - test('new chat remains assigned to the group where creation started', async () => { const { sessionsService, view } = createHarness(disposables); const main = createChat('main'); @@ -447,4 +433,26 @@ suite('Sessions - ChatGroupsView', () => { focusInMainGroup: true, }); }); + + test('shows session actions in a single tab row and hides them for split groups', () => { + const { view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + const session = new TestActiveSession([main, secondary]); + view.setSession(session, options); + + const singleGroupActions = view.element.querySelector('.session-chat-tabs-actions'); + const singleGroupHidden = singleGroupActions?.classList.contains('hidden'); + view.splitChatToSide(secondary.resource); + const splitGroupActions = Array.from(view.element.querySelectorAll('.session-chat-tabs-actions')); + + assert.deepStrictEqual({ + singleGroupHidden, + splitGroupsHidden: splitGroupActions.map(actions => actions.classList.contains('hidden')), + }, { + singleGroupHidden: false, + splitGroupsHidden: [true, true], + }); + }); + }); diff --git a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts index 95b3fb8c724c3..a194d0f41a70b 100644 --- a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts +++ b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts @@ -4,14 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Codicon } from '../../../base/common/codicons.js'; -import { toAction } from '../../../base/common/actions.js'; import { extUri } from '../../../base/common/resources.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 { ISessionConversationActionMetadata, toSessionConversationDropdownActions } from '../../browser/parts/sessionConversationsActionViewItem.js'; -import { getSelectedSessionConversationActionId, getSessionConversationActionId, getSessionConversationGroupId, getSessionConversationStatusAriaLabel, getSessionConversationStatusDescription, getSessionConversationStatusLabel, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../../browser/sessionConversationGroups.js'; +import { getSessionConversationGroupId, getSessionConversationStatusAriaLabel, getSessionConversationStatusLabel, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../../browser/sessionConversationGroups.js'; import { ChatOriginKind, IChat, IChatOrigin, SessionStatus } from '../../services/sessions/common/session.js'; function createChat(id: string, origin?: IChatOrigin): IChat { @@ -39,105 +36,7 @@ suite('Sessions - Session conversation groups', () => { ]); }); - test('selects the active chat or subagent directly', () => { - const parentChat = createChat('parent'); - const activeSubagent = createChat('active-subagent', { kind: ChatOriginKind.Tool, parentChat: parentChat.resource }); - const activeSideChat = createChat('active-side-chat', { kind: ChatOriginKind.SideChat, parentChat: parentChat.resource }); - - assert.deepStrictEqual({ - subagent: getSelectedSessionConversationActionId('session', activeSubagent), - sideChat: getSelectedSessionConversationActionId('session', activeSideChat), - }, { - subagent: getSessionConversationActionId('session', activeSubagent.resource), - sideChat: getSessionConversationActionId('session', activeSideChat.resource), - }); - }); - - test('adapts flat chat and subagent groups with state', async () => { - let runCount = 0; - const firstChatAction = toAction({ - id: getSessionConversationActionId('session', URI.parse('test-chat:/parent-1')), - label: 'First Chat', - enabled: false, - run: () => runCount++, - }); - const secondChatAction = toAction({ - id: getSessionConversationActionId('session', URI.parse('test-chat:/parent-2')), - label: 'Second Chat', - run: () => runCount++, - }); - const firstSubagentAction = toAction({ - id: 'test.subagent.1', - label: 'Research', - run: () => runCount++, - }); - const metadata = new Map([ - [firstChatAction.id, { description: 'In Progress', ariaDescription: 'State: In Progress', icon: Codicon.sessionInProgress }], - [firstSubagentAction.id, { description: 'Completed', ariaDescription: 'State: Completed', icon: Codicon.circleSmallFilled }], - ]); - const actions = toSessionConversationDropdownActions([ - [SESSION_CONVERSATION_CHATS_GROUP, [firstChatAction, secondChatAction]], - [SESSION_CONVERSATION_SUBAGENTS_GROUP, [firstSubagentAction]], - ], metadata); - - await actions[0].run(); - - assert.deepStrictEqual({ - actions: actions.map(action => ({ - label: action.label, - description: action.description, - ariaDescription: action.ariaDescription, - category: action.category, - })), - runCount, - }, { - actions: [ - { - label: 'First Chat', - description: 'In Progress', - ariaDescription: 'State: In Progress', - category: { label: 'Chats', order: 1, showHeader: false }, - }, - { - label: 'Second Chat', - description: undefined, - ariaDescription: undefined, - category: { label: 'Chats', order: 1, showHeader: false }, - }, - { - label: 'Research', - description: 'Completed', - ariaDescription: 'State: Completed', - category: { label: 'Subagents', order: 2, showHeader: true }, - }, - ], - runCount: 1, - }); - }); - - test('shows only subagents when there is one first-level chat', () => { - const chatAction = toAction({ - id: getSessionConversationActionId('session', URI.parse('test-chat:/parent')), - label: 'Only Chat', - run: () => { }, - }); - const subagentAction = toAction({ id: 'test.subagent', label: 'Research', run: () => { } }); - - const actions = toSessionConversationDropdownActions([ - [SESSION_CONVERSATION_CHATS_GROUP, [chatAction]], - [SESSION_CONVERSATION_SUBAGENTS_GROUP, [subagentAction]], - ]); - - assert.deepStrictEqual(actions.map(action => ({ - label: action.label, - category: action.category?.label, - showHeader: action.category?.showHeader, - })), [ - { label: 'Research', category: 'Subagents', showHeader: true }, - ]); - }); - - test('localizes every conversation state', () => { + test('localizes every conversation state for accessibility', () => { assert.deepStrictEqual([ SessionStatus.Untitled, SessionStatus.InProgress, @@ -156,19 +55,4 @@ suite('Sessions - Session conversation groups', () => { ]); }); - test('keeps completed state visually quiet but accessible', () => { - assert.deepStrictEqual([ - SessionStatus.Untitled, - SessionStatus.InProgress, - SessionStatus.NeedsInput, - SessionStatus.Completed, - SessionStatus.Error, - ].map(status => getSessionConversationStatusDescription(status)), [ - 'New', - 'In Progress', - 'Input Needed', - undefined, - 'Failed', - ]); - }); }); diff --git a/src/vs/sessions/test/browser/sessionHeader.test.ts b/src/vs/sessions/test/browser/sessionHeader.test.ts index 059d8c2bfe3f9..601b50d6adae5 100644 --- a/src/vs/sessions/test/browser/sessionHeader.test.ts +++ b/src/vs/sessions/test/browser/sessionHeader.test.ts @@ -123,7 +123,23 @@ suite('Sessions - SessionHeader', () => { assert.strictEqual(dragEvent.defaultPrevented, false); }); - test('shows read-only workspace metadata beside the title and hides the second row when configured', () => { + test('hides the header while it is replaced by the single-group tabs row', () => { + const { header } = createHarness(disposables); + + header.setVisible(false); + const hiddenDisplay = header.element.style.display; + header.setVisible(true); + + assert.deepStrictEqual({ + hiddenDisplay, + restoredDisplay: header.element.style.display, + }, { + hiddenDisplay: 'none', + restoredDisplay: '', + }); + }); + + test('does not show workspace metadata beside the title and hides the second row when configured', () => { const root = URI.file('C:\\Code\\vscode'); const workspace: ISessionWorkspace = { uri: root, @@ -144,14 +160,10 @@ suite('Sessions - SessionHeader', () => { const metaRow = header.element.querySelector('.chat-composite-bar-meta-row'); assert.deepStrictEqual({ - workspaceText: workspaceMeta?.textContent, - workspaceHidden: workspaceMeta?.classList.contains('hidden'), - workspaceFocusable: workspaceMeta?.tabIndex, + workspaceMeta, metaRowDisplay: metaRow?.style.display, }, { - workspaceText: '·vscode', - workspaceHidden: false, - workspaceFocusable: -1, + workspaceMeta: null, metaRowDisplay: 'none', }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts index 93cc4d932a3c5..5f11c34aaa24a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts @@ -67,6 +67,7 @@ function createMockDelegate(session: IActiveSession, chats: readonly IChat[], ac activeChatResource: observableValue('activeChatResource', activeChat.resource.toString()), mainChatResource: observableValue('mainChatResource', chats[0].resource.toString()), visible: session.shouldShowChatTabs, + showSessionActions: session.shouldShowChatTabs, openChat: () => { }, newChat: () => { }, }; @@ -98,6 +99,7 @@ function renderBar(ctx: ComponentFixtureContext, chats: readonly IChat[], active container.style.width = '360px'; container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + container.classList.add('chat-groups-view', 'single-group'); const session = createMockSession(chats, activeChat, sessionTitle); const bar = disposableStore.add(instantiationService.createInstance(ChatCompositeBar)); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index 1748c2b79272f..d75e7f56d75d6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -36,15 +36,21 @@ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, // Mock helpers // ============================================================================ -function createMockActiveSession(title: string, workspaceLabel: string): IActiveSession { - const workspace = new class extends mock() { - override readonly label = workspaceLabel; - }(); +function createMockActiveSession(title: string, workspaceLabel?: string): IActiveSession { + let workspace: ISessionWorkspace | undefined; + if (workspaceLabel) { + const label = workspaceLabel; + workspace = new class extends mock() { + override readonly label = label; + override readonly folders = []; + override readonly isVirtualWorkspace = false; + }(); + } return new class extends mock() { override readonly icon = Codicon.copilot; override readonly title: IObservable = constObservable(title); override readonly workspace: IObservable = constObservable(workspace); - override readonly isQuickChat: IObservable = constObservable(false); + override readonly isQuickChat: IObservable = constObservable(workspace === undefined); }(); } @@ -185,13 +191,19 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo export default defineThemedFixtureGroup({ path: 'sessions/' }, { - // Default: shows the active session pill (icon + title + workspace). + // Default: shows the active session workspace. SessionsTitleBar_ActiveSession: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), }), }), + SessionsTitleBar_NoWorkspace: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Quick chat'), + }), + }), + // Requires-input: generic orange state (a mix, or unclassified needs-input). SessionsTitleBar_RequiresInput: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { From 310ba23388fe02f6e3f53e3c91ae004ecdd8ed52 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 20 Aug 2026 14:05:26 -0700 Subject: [PATCH 22/29] Persist state between profiled startup runs (#331860) perf: persist state between startup runs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/startupTimings.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/performance/electron-browser/startupTimings.ts b/src/vs/workbench/contrib/performance/electron-browser/startupTimings.ts index fe0d86ad970ba..a5806826a973b 100644 --- a/src/vs/workbench/contrib/performance/electron-browser/startupTimings.ts +++ b/src/vs/workbench/contrib/performance/electron-browser/startupTimings.ts @@ -21,6 +21,7 @@ import { IWorkspaceTrustManagementService } from '../../../../platform/workspace import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js'; import { StartupTimings } from '../browser/startupTimings.js'; import { coalesce } from '../../../../base/common/arrays.js'; +import { IStorageService, WillSaveStateReason } from '../../../../platform/storage/common/storage.js'; interface ITracingData { readonly args?: { @@ -53,7 +54,8 @@ export class NativeStartupTimings extends StartupTimings implements IWorkbenchCo @IUpdateService updateService: IUpdateService, @INativeWorkbenchEnvironmentService private readonly _environmentService: INativeWorkbenchEnvironmentService, @IProductService private readonly _productService: IProductService, - @IWorkspaceTrustManagementService workspaceTrustService: IWorkspaceTrustManagementService + @IWorkspaceTrustManagementService workspaceTrustService: IWorkspaceTrustManagementService, + @IStorageService private readonly _storageService: IStorageService, ) { super(editorService, paneCompositeService, lifecycleService, updateService, workspaceTrustService); @@ -128,7 +130,18 @@ export class NativeStartupTimings extends StartupTimings implements IWorkbenchCo } catch (err) { console.error(err); } finally { - this._nativeHostService.exit(0); + let exitCode = 0; + try { + await Promise.race([ + this._storageService.flush(WillSaveStateReason.SHUTDOWN), + timeout(5000).then(() => { throw new Error('Timed out flushing profiled startup state.'); }), + ]); + } catch (error) { + exitCode = 1; + console.error(error); + } finally { + this._nativeHostService.exit(exitCode); + } } } From 506d2e1cc5e99c013721feb7c49de0afb9d189d1 Mon Sep 17 00:00:00 2001 From: Ben Villalobos Date: Thu, 20 Aug 2026 14:07:17 -0700 Subject: [PATCH 23/29] Polish automation run history list (#331866) * sessions: polish automation run history list --- .../browser/media/automationsCards.css | 22 +++++++++++++++++-- .../sessions/browser/views/automationsView.ts | 5 +++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css index 048d9d368debd..576d8ad980d26 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css +++ b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css @@ -320,14 +320,32 @@ margin-bottom: 8px; } +.automations-history-group-runs { + margin: 0 var(--vscode-spacing-size100); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; +} + +.automations-history-group-runs .automations-run-session-list .monaco-list-row.session-list-inset-row { + margin: 0; + width: 100%; + border-radius: 0; +} + +.automations-history-group-runs .automations-temporary-run:not(:first-child), +.automations-history-group-runs .automations-run-session-list:not(:empty) .monaco-list-row:not(:first-child), +.automations-history-group-runs .automations-temporary-runs:not(:empty) + .automations-run-session-list:not(:empty) { + border-top: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); +} + .automations-temporary-runs:empty { display: none; } .automations-temporary-run { height: 54px; - margin: 0 var(--vscode-spacing-size100); - width: calc(100% - var(--vscode-spacing-size200)); + width: 100%; } .hc-black .automations-card, diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index abed31eecf62f..fa408bc24e733 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -695,8 +695,9 @@ class AutomationHistorySection extends Disposable { const element = $('.automations-history-group'); const header = DOM.append(element, $('.automations-history-group-header')); header.textContent = label; - const temporaryRowsContainer = DOM.append(element, $('.automations-temporary-runs')); - const listContainer = DOM.append(element, $('.automations-run-session-list')); + const runsContainer = DOM.append(element, $('.automations-history-group-runs')); + const temporaryRowsContainer = DOM.append(runsContainer, $('.automations-temporary-runs')); + const listContainer = DOM.append(runsContainer, $('.automations-run-session-list')); const runsBySession = new Map(); const entry: IAutomationHistoryGroup = { From 5270254e0e6dc04e24baa255de73d47522ce7943 Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:25:56 -0700 Subject: [PATCH 24/29] Let the setup banner reload an agent's configuration (#331848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Let the setup banner reload an agent's configuration A user who finishes setup outside the app — `claude login` in a terminal, an exported key — leaves no signal the app can see, so the banner kept asking them to sign in to something they had already signed in to. Give them a way to say "look again", and rename the docs link to "learn more" now that it is one of two links rather than the only one. The re-look is the tail of a download promoted to its own gesture: restart chat discovery, then refresh models. `AgentSdkSetupChannel` grows a second request key rather than per-agent code, so agent #3 still needs no edit here — one consumed nonce per key, cleared as it is claimed, so a repeat press still lands. The reload clause folds into each of the four `noAccount` sentences rather than trailing them: it is unconditional, so the table stays at four branches and no localized string is assembled from fragments. * Rank the no-account copy as the buttons rank it, and harden its links Read the sentence in the order the routes are weighted: GitHub sign-in leads, as the primary button; the provider sign-in follows; reload and docs trail, being the copy's only links rather than buttons. Reload and docs become their own sentences — kept as trailing clauses they would have fallen under the "if you already set up Claude elsewhere" conditional, which does not scope docs. Addresses review feedback: build both `command:` hrefs through `createCommandUri` instead of by hand (`encodeURIComponent` leaves `)` alone, so an agent id containing one closed the markdown link destination early), and escape the host-supplied display name and sign-in provider before interpolating them into markdown this banner trusts for two commands. Co-Authored-By: Claude Opus 5 * Rewrite the no-account copy, and point Claude at its integrations docs The four sentences now put every sign-in route and the reload into one "or" list, ranked as the buttons rank them, and give the docs their own trailing sentence. Claude's docs URL moves to the third-party integrations page, which is what "other ways to set up Claude" actually means: Console, Bedrock, Vertex, Foundry, Teams and Enterprise. "Set up" is the verb, two words, as the rest of the string already had it. Both agents' URL constants still described the workbench as labelling a button. It has been a link since docs stopped being an action. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../agentHost/common/agentSdkSetup.ts | 6 ++ .../agentHost/node/agentSdkSetupChannel.ts | 53 +++++++++++++----- .../agentHost/node/claude/claudeAgent.ts | 4 +- .../agentHost/node/codex/codexAgent.ts | 2 +- .../agentHost/test/node/claudeAgent.test.ts | 55 +++++++++++++++++- .../test/node/codex/codexModelRefresh.test.ts | 25 ++++++++- .../agentHostSdkSetupNotification.ts | 42 +++++++++----- .../agentHostSdkSetupNotification.test.ts | 56 +++++++++++++++---- .../agentHost/browser/agentSdkSetupService.ts | 36 +++++++++--- 9 files changed, 225 insertions(+), 54 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentSdkSetup.ts b/src/vs/platform/agentHost/common/agentSdkSetup.ts index 953baebb2508e..d37ecf55d4a5a 100644 --- a/src/vs/platform/agentHost/common/agentSdkSetup.ts +++ b/src/vs/platform/agentHost/common/agentSdkSetup.ts @@ -18,6 +18,12 @@ const AGENT_SDK_SETUP_STATUS_KEY_PREFIX = 'vscode.agentSdkSetup.status.'; export const AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY = 'vscode.agentSdkSetup.downloadRequest'; +/** + * Ask an agent to look again at a setup the user completed outside the app + * (`claude login`, an exported key) — the only completion signal there is. + */ +export const AGENT_SDK_SETUP_RELOAD_REQUEST_KEY = 'vscode.agentSdkSetup.reloadRequest'; + export function agentSdkSetupStatusKey(agent: string): string { return `${AGENT_SDK_SETUP_STATUS_KEY_PREFIX}${agent}`; } diff --git a/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts b/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts index 98efa3127933a..3e04c540b15a1 100644 --- a/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts +++ b/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts @@ -5,7 +5,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; -import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AgentSdkDownloadStatus, IAgentSdkSetupInfo, agentSdkSetupStatusKey, isAgentSdkSetupRequestFor } from '../common/agentSdkSetup.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, AgentSdkDownloadStatus, IAgentSdkSetupInfo, agentSdkSetupStatusKey, isAgentSdkSetupRequestFor } from '../common/agentSdkSetup.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentSdkDownloader, IAgentSdkPackage } from './agentSdkDownloader.js'; @@ -33,14 +33,14 @@ export interface IAgentSdkSetupChannelAgent { /** * One agent's side of the SDK setup channel: publishes whether its SDK is on - * disk, and performs the download the workbench asks for. Every agent needs the - * same nonce handling, latching and publish ordering, so only the calls in - * {@link IAgentSdkSetupChannelAgent} differ. + * disk, performs the download the workbench asks for, and looks again when it + * asks for that. Every agent needs the same nonce handling, latching and publish + * ordering, so only the calls in {@link IAgentSdkSetupChannelAgent} differ. */ export class AgentSdkSetupChannel extends Disposable { - /** Consumed request nonce, so a root-config change we caused isn't re-handled. */ - private _lastRequest: string | undefined; + /** Consumed request nonce per request key, so a root-config change we caused isn't re-handled. */ + private readonly _lastRequests = new Map(); /** * Latched while the *explicit* download runs. {@link IAgentSdkSetupChannelAgent.isSdkLocal} @@ -80,13 +80,27 @@ export class AgentSdkSetupChannel extends Disposable { } private _handleRequest(): void { - const request = this._configurationService.getRootConfigValues?.()[AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]; - if (!isAgentSdkSetupRequestFor(request, this._agent.id) || request.request === this._lastRequest) { - return; + const values = this._configurationService.getRootConfigValues?.() ?? {}; + if (this._takeRequest(values, AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY)) { + void this._download(); + } + if (this._takeRequest(values, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY)) { + this._logService.info(`[AgentSdkSetup] ${this._agent.id}: reloading the agent's configuration at the user's request`); + // Nothing to publish: the SDK is already on disk either way, and what the + // banner reads is the catalog the re-look republishes. + void this._lookAgain(); + } + } + + /** Claim one request addressed to this agent, clearing the key so a repeat press still lands. */ + private _takeRequest(values: Readonly>, key: string): boolean { + const request = values[key]; + if (!isAgentSdkSetupRequestFor(request, this._agent.id) || request.request === this._lastRequests.get(key)) { + return false; } - this._lastRequest = request.request; - this._configurationService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: undefined }); - void this._download(); + this._lastRequests.set(key, request.request); + this._configurationService.updateRootConfig({ [key]: undefined }); + return true; } /** @@ -110,12 +124,21 @@ export class AgentSdkSetupChannel extends Disposable { this._downloadInFlight = false; progressInterest.dispose(); } + await this._lookAgain(); + } + + /** + * Re-read the world: the tail of a download, and the whole of a reload. Both + * gestures change exactly what these two calls see — one puts the SDK on disk, + * the other follows a `claude login` the app could not observe. + */ + private async _lookAgain(): Promise { // Chat discovery deferred itself while there was no SDK to read the catalog // from; this is the one moment that can change. this._agent.restartChatDiscovery(); - // Second, not first: the refresh is what asks the fresh SDK about the account, - // so announcing `ready` ahead of it would show "no account found" to a user - // who has one for as long as enumeration takes. + // Second, not first: the refresh is what asks the SDK about the account, so + // announcing `ready` ahead of it would show "no account found" to a user who + // has one for as long as enumeration takes. await this._agent.refreshModels(); } } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 6c28556a625a7..b5a70e97d81a6 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -68,8 +68,8 @@ import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js const USER_AGENT_PREFIX = 'vscode_claude_code'; -/** Where a user goes to establish Claude credentials; the workbench labels the button. */ -const CLAUDE_SETUP_DOCS_URL = 'https://docs.claude.com/en/docs/claude-code/setup'; +/** Where a user goes to establish Claude credentials; the workbench labels the link. */ +const CLAUDE_SETUP_DOCS_URL = 'https://code.claude.com/docs/en/third-party-integrations'; /** * Returns true if `m` is a Claude-family model that should be advertised diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 80266c48a93aa..4f18e4361684e 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -175,7 +175,7 @@ const CODEX_THINKING_LEVEL_KEY = 'thinkingLevel'; */ const USER_AGENT_PREFIX = 'vscode_codex'; -/** Where a user finishes setting Codex up outside the app; the workbench labels the button. */ +/** Where a user finishes setting Codex up outside the app; the workbench labels the link. */ const CODEX_SETUP_DOCS_URL = 'https://learn.chatgpt.com/codex/auth'; /** diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index aa093bd0ce65a..f9e27fd7b8fbc 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -77,7 +77,7 @@ import { createClaudeInternalMcpServerCustomization } from '../../node/claude/cu import { ClaudeSessionMetadataStore } from '../../node/claude/claudeSessionMetadataStore.js'; import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'; import { ClaudeAgentSdkService, IClaudeAgentSdkService, IClaudeSdkBindings } from '../../node/claude/claudeAgentSdkService.js'; -import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../common/agentSdkSetup.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../common/agentSdkSetup.js'; import { IAgentSdkDownloader } from '../../node/agentSdkDownloader.js'; import { RecordingAgentSdkDownloader } from './testAgentSdkDownloader.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; @@ -6073,6 +6073,11 @@ suite('ClaudeAgent — agent SDK setup channel', () => { ctx.configService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request } }); } + /** Addresses a reload request the same way, as the banner's link does. */ + function dispatchReload(ctx: ITestContext, agent = 'claude', request = 'req-1'): void { + ctx.configService.updateRootConfig({ [AGENT_SDK_SETUP_RELOAD_REQUEST_KEY]: { agent, request } }); + } + /** Waits for the ctor's queued publish (and any refresh it chains) to settle. */ async function settle(): Promise { for (let i = 0; i < 20; i++) { @@ -6087,7 +6092,7 @@ suite('ClaudeAgent — agent SDK setup channel', () => { assert.deepStrictEqual(readSetup(ctx), { agent: 'claude', download: 'ready', - setupDocsUrl: 'https://docs.claude.com/en/docs/claude-code/setup', + setupDocsUrl: 'https://code.claude.com/docs/en/third-party-integrations', // No in-app sign-in: every Claude credential is established outside the // app, so the banner can only point at the docs. signInProviderName: undefined, @@ -6264,6 +6269,52 @@ suite('ClaudeAgent — agent SDK setup channel', () => { migratable: [], }); }); + + test('a reload re-asks the SDK for the account the user set up elsewhere, fetching nothing', async () => { + // Setup happens outside the app, so nothing fires when it finishes — a fresh + // `accountInfo()` is the only way to see it, and the SDK is already on disk. + const ctx = createTestContext(disposables); + await settle(); + const before = ctx.sdk.accountInfoCallCount; + ctx.sdk.accountInfoResult = NATIVE_ACCOUNT; + ctx.sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] }, + ]; + + dispatchReload(ctx); + await settle(); + + assert.deepStrictEqual({ + asked: ctx.sdk.accountInfoCallCount > before, + fetches: ctx.sdk.ensureAvailableCalls, + models: ctx.agent.models.get().map(model => model.name), + // Consumed like the download key, so pressing the link twice is two reloads. + key: ctx.configService.getRootConfigValues()[AGENT_SDK_SETUP_RELOAD_REQUEST_KEY], + }, { + asked: true, + fetches: 0, + models: ['Claude Sonnet 4.5'], + key: undefined, + }); + }); + + test('a reload addressed to another agent is ignored', async () => { + const ctx = createTestContext(disposables); + await settle(); + const before = ctx.sdk.accountInfoCallCount; + + dispatchReload(ctx, 'codex'); + await settle(); + + assert.deepStrictEqual({ + asked: ctx.sdk.accountInfoCallCount > before, + // Left in place for the agent it names, rather than consumed by this one. + key: ctx.configService.getRootConfigValues()[AGENT_SDK_SETUP_RELOAD_REQUEST_KEY], + }, { + asked: false, + key: { agent: 'codex', request: 'req-1' }, + }); + }); }); suite('ClaudeAgent — per-session provider', () => { diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index b639fbba1726d..c85f706402d68 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -21,7 +21,7 @@ import { IAgentHostSessionTitleSignal } from '../../../node/agentHostSessionTitl import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js'; import { RecordingAgentSdkDownloader } from '../testAgentSdkDownloader.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js'; -import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../../common/agentSdkSetup.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../../common/agentSdkSetup.js'; import { CodexAgent, toCodexModelSelectionId } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; @@ -681,6 +681,11 @@ suite('CodexAgent — agent SDK setup channel', () => { ctx.configurationService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request } }); } + /** Addresses a reload request the same way, as the banner's link does. */ + function dispatchReload(ctx: ITestAgentContext, agent = 'codex', request = 'req-1'): void { + ctx.configurationService.updateRootConfig({ [AGENT_SDK_SETUP_RELOAD_REQUEST_KEY]: { agent, request } }); + } + /** Waits for the ctor's queued publish (and any refresh it chains) to settle. */ async function settle(): Promise { for (let i = 0; i < 20; i++) { @@ -840,4 +845,22 @@ suite('CodexAgent — agent SDK setup channel', () => { held: 0, }); }); + + test('a reload is claimed here too, since the request handling is the shared channel and not per-agent code', async () => { + const ctx = createAgentContext(disposables, async () => []); + ctx.agent['_ensureConnection'] = async () => { throw new Error('offline'); }; + await settle(); + + dispatchReload(ctx); + await settle(); + + assert.deepStrictEqual({ + key: ctx.configurationService.getRootConfigValues()[AGENT_SDK_SETUP_RELOAD_REQUEST_KEY], + // Reload only re-reads what is already there; nothing is ever fetched. + interests: ctx.sdkDownloader.progressInterests, + }, { + key: undefined, + interests: [], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts index b7115292dcd62..e48a275a2c181 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { Event } from '../../../../../../base/common/event.js'; +import { createCommandUri, escapeMarkdownSyntaxTokens, IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../../nls.js'; import { AgentHostAllowSignedOutWhenUsableSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; @@ -84,25 +85,39 @@ export function getAgentSdkSetupStateToReport(previous: AgentSdkSetupState | und // #region Banner +/** Trusted for the commands its links address, and nothing else. */ +function setupMarkdown(value: string): MarkdownString { + return new MarkdownString(value, { isTrusted: { enabledCommands: [AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_RELOAD_COMMAND_ID] } }); +} + /** * The "no account" second line: one whole sentence per combination of routes, * never assembled from localized fragments, because clause order is not stable - * across languages. The GitHub clause is unconditional — every agent behind this - * banner reaches models through our Copilot proxy once signed in, which is - * workbench knowledge rather than something an agent could declare. + * across languages. The routes share one "or" list, ranked as the buttons rank + * them and led by the unconditional GitHub clause: reaching models through our + * Copilot proxy is workbench knowledge, not something an agent declares. */ -function noAccountDescription(setup: IAgentSdkSetupInfo, displayName: string): string { - const provider = setup.signInProviderName; - if (provider && setup.setupDocsUrl) { - return localize('agentHost.sdkSetup.noAccountDescription.all', "Sign in to GitHub to use GitHub Copilot models, sign in to {0} to use your {0} subscription, or read the instructions for other ways to set up {1}.", provider, displayName); +function noAccountDescription(setup: IAgentSdkSetupInfo, displayName: string): IMarkdownString { + // Both nouns are the host's, and this string is trusted for two commands, so + // they are escaped rather than interpolated raw: `[]()` in a name would + // otherwise synthesize a link to either one. + const name = escapeMarkdownSyntaxTokens(displayName); + const provider = setup.signInProviderName && escapeMarkdownSyntaxTokens(setup.signInProviderName); + // `command:` hrefs, so a link in the copy takes the same route a button would — + // funnel step and URL validation included. Both carry the agent id and nothing + // else: the docs command resolves the URL from the agent's own declaration. + const reload = createCommandUri(AGENT_SDK_SETUP_RELOAD_COMMAND_ID, setup.agent).toString(); + const docs = setup.setupDocsUrl ? createCommandUri(AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, setup.agent).toString() : undefined; + if (provider && docs) { + return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription.all', "Sign in to GitHub to use GitHub Copilot models, sign in to {2} to use your {2} subscription, or [reload the configuration]({1}) if you have set up {0} elsewhere. For other ways to set up {0}, [learn more]({3}) on their docs.", name, reload, provider, docs)); } if (provider) { - return localize('agentHost.sdkSetup.noAccountDescription.signIn', "Sign in to GitHub to use GitHub Copilot models, or sign in to {0} to use your {0} subscription.", provider); + return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription.signIn', "Sign in to GitHub to use GitHub Copilot models, sign in to {2} to use your {2} subscription, or [reload the configuration]({1}) if you have set up {0} elsewhere.", name, reload, provider)); } - if (setup.setupDocsUrl) { - return localize('agentHost.sdkSetup.noAccountDescription.docs', "Sign in to GitHub to use GitHub Copilot models, or read the instructions for other ways to set up {0}.", displayName); + if (docs) { + return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription.docs', "Sign in to GitHub to use GitHub Copilot models or [reload the configuration]({1}) if you have set up {0} elsewhere. For other ways to set up {0}, [learn more]({2}) on their docs.", name, reload, docs)); } - return localize('agentHost.sdkSetup.noAccountDescription', "Sign in to GitHub to use GitHub Copilot models."); + return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription', "Sign in to GitHub to use GitHub Copilot models or [reload the configuration]({1}) if you have set up {0} elsewhere.", name, reload)); } /** @@ -197,9 +212,6 @@ export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displ }; } const actions: IChatInputNotificationAction[] = []; - if (setup.setupDocsUrl) { - actions.push(action(localize('agentHost.sdkSetup.docsAction', "Setup Instructions"), AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID)); - } if (setup.signInProviderName) { actions.push(action(localize('agentHost.sdkSetup.signInAction', "Sign in to {0}", setup.signInProviderName), AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID)); } @@ -220,6 +232,7 @@ export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displ export const AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID = 'workbench.action.chat.agentHost.downloadAgentSdk'; export const AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID = 'workbench.action.chat.agentHost.openAgentSetupDocs'; +export const AGENT_SDK_SETUP_RELOAD_COMMAND_ID = 'workbench.action.chat.agentHost.reloadAgentConfiguration'; export const AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID = 'workbench.action.chat.agentHost.signInToGitHubForAgent'; export const AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID = 'workbench.action.chat.agentHost.signInToAgent'; @@ -239,6 +252,7 @@ function registerAgentSdkSetupCommand(id: string, run: (setupService: IAgentSdkS registerAgentSdkSetupCommand(AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, (setupService, agent) => setupService.requestDownload(agent)); registerAgentSdkSetupCommand(AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, (setupService, agent) => setupService.openSetupDocs(agent)); +registerAgentSdkSetupCommand(AGENT_SDK_SETUP_RELOAD_COMMAND_ID, (setupService, agent) => setupService.requestReload(agent)); registerAgentSdkSetupCommand(AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, (setupService, agent) => setupService.signInToGitHub(agent)); registerAgentSdkSetupCommand(AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, (setupService, agent) => setupService.signIn(agent)); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts index ccf3311fbacdb..0ccad83e4ff2c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import type { IAgentSdkSetupInfo } from '../../../../../../platform/agentHost/common/agentSdkSetup.js'; -import { AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, agentSdkSetupNotificationId, createAgentSdkSetupNotification, getAgentDisplayNames, getAgentSdkSetupState, getAgentSdkSetupStateToReport, hasAgentSdkSetupNotification, type IAgentSdkSetupStateInputs } from '../../../browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_RELOAD_COMMAND_ID, AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, agentSdkSetupNotificationId, createAgentSdkSetupNotification, getAgentDisplayNames, getAgentSdkSetupState, getAgentSdkSetupStateToReport, hasAgentSdkSetupNotification, type IAgentSdkSetupStateInputs } from '../../../browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; import type { AgentSdkSetupState } from '../../../../../services/agentHost/browser/agentSdkSetupService.js'; import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, type IChatInputNotification, type IChatInputNotificationAction, type IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js'; import { SessionType } from '../../../common/chatSessionsService.js'; @@ -97,9 +97,11 @@ suite('Agent SDK setup banner', () => { both: buttons(codex, 'Codex'), neither: buttons({ agent: 'some-future-agent', download: 'ready' }, 'Future'), }, { - docsOnly: [AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], + // Docs are a link in the description, never a button — so declaring a + // docs URL and declaring nothing produce the same row of buttons. + docsOnly: [AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], signInOnly: [AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], - both: [AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], + both: [AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], neither: [AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID], }); }); @@ -114,12 +116,22 @@ suite('Agent SDK setup banner', () => { assert.deepStrictEqual(notification.actions.map(action => action.label), ['Sign in to ChatGPT', 'Sign in to GitHub']); }); - test('the routes named in the copy are the ones the agent declared', () => { + test('the routes named in the copy are the ones the agent declared, ranked as the buttons rank them', () => { // One whole sentence per combination rather than joined clauses, since a // translator reorders them freely. GitHub appears in all four: every agent // behind this banner reaches models through our proxy once signed in. - const noAccount = (setup: Omit) => - createAgentSdkSetupNotification({ agent: 'claude', download: 'ready', ...setup }, 'Claude', 'noAccount')?.description; + const noAccount = (setup: Omit) => { + const description = createAgentSdkSetupNotification({ agent: 'claude', download: 'ready', ...setup }, 'Claude', 'noAccount')?.description; + return typeof description === 'string' ? description : description?.value; + }; + // Leads every variant, as the primary button does. + const gitHub = 'Sign in to GitHub to use GitHub Copilot models'; + // Unconditional: setup finished in a terminal has no completion signal, so + // every agent needs the "look again" route whatever else it declares. + const reload = `[reload the configuration](command:${AGENT_SDK_SETUP_RELOAD_COMMAND_ID}?%255B%2522claude%2522%255D) if you have set up Claude elsewhere.`; + // The agent id, like every button carries — the command resolves the URL + // from the agent's own declaration rather than trusting the banner's copy. + const docs = `For other ways to set up Claude, [learn more](command:${AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID}?%255B%2522claude%2522%255D) on their docs.`; assert.deepStrictEqual({ gitHubOnly: noAccount({}), @@ -127,13 +139,37 @@ suite('Agent SDK setup banner', () => { signIn: noAccount({ signInProviderName: 'ChatGPT' }), both: noAccount({ setupDocsUrl: 'https://example.test/claude', signInProviderName: 'ChatGPT' }), }, { - gitHubOnly: 'Sign in to GitHub to use GitHub Copilot models.', - docs: 'Sign in to GitHub to use GitHub Copilot models, or read the instructions for other ways to set up Claude.', - signIn: 'Sign in to GitHub to use GitHub Copilot models, or sign in to ChatGPT to use your ChatGPT subscription.', - both: 'Sign in to GitHub to use GitHub Copilot models, sign in to ChatGPT to use your ChatGPT subscription, or read the instructions for other ways to set up Claude.', + gitHubOnly: `${gitHub} or ${reload}`, + docs: `${gitHub} or ${reload} ${docs}`, + signIn: `${gitHub}, sign in to ChatGPT to use your ChatGPT subscription, or ${reload}`, + both: `${gitHub}, sign in to ChatGPT to use your ChatGPT subscription, or ${reload} ${docs}`, }); }); + test('a name carrying markdown is escaped, so the host cannot forge a third link', () => { + // Both nouns arrive from the host, and this description is trusted for two + // commands — an unescaped `[]()` in either would render as a link to one of + // them instead of as the name. + const description = createAgentSdkSetupNotification( + { agent: 'claude', download: 'ready', setupDocsUrl: 'https://example.test/claude', signInProviderName: 'Chat[G]PT' }, + 'Claude [x](command:evil)', + 'noAccount', + )?.description; + const name = 'Claude \\[x\\]\\(command:evil\\)'; + + assert.strictEqual(typeof description === 'string' ? description : description?.value, + `Sign in to GitHub to use GitHub Copilot models, sign in to Chat\\[G\\]PT to use your Chat\\[G\\]PT subscription, or [reload the configuration](command:${AGENT_SDK_SETUP_RELOAD_COMMAND_ID}?%255B%2522claude%2522%255D) if you have set up ${name} elsewhere. For other ways to set up ${name}, [learn more](command:${AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID}?%255B%2522claude%2522%255D) on their docs.`); + }); + + test('the copy is trusted for its own two commands alone, so its links render and reach nothing else', () => { + // Untrusted markdown renders a `command:` link as inert text, which would + // leave both routes with no affordance at all now that neither has a button. + const description = createAgentSdkSetupNotification({ agent: 'claude', download: 'ready', setupDocsUrl: 'https://example.test/claude' }, 'Claude', 'noAccount')?.description; + + assert.ok(description !== undefined && typeof description !== 'string'); + assert.deepStrictEqual(description.isTrusted, { enabledCommands: [AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_RELOAD_COMMAND_ID] }); + }); + test('the banner cannot be dismissed, since it is the only route to a working agent', () => { const notification = createAgentSdkSetupNotification(claude, 'Claude', 'downloadOffered'); diff --git a/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts b/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts index f47ab455febcd..42a1709fc1a44 100644 --- a/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts +++ b/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, IAgentSdkSetupInfo, readAgentSdkSetupInfos, readConsentedSdkAgents, resolveConsentedSdkDownloads, writeConsentedSdkAgents } from '../../../../platform/agentHost/common/agentSdkSetup.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, IAgentSdkSetupInfo, readAgentSdkSetupInfos, readConsentedSdkAgents, resolveConsentedSdkDownloads, writeConsentedSdkAgents } from '../../../../platform/agentHost/common/agentSdkSetup.js'; import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { ROOT_STATE_URI } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -51,7 +51,8 @@ type AgentSdkSetupFunnelStep = | 'consentedDownload' | 'docsClicked' | 'gitHubSignInClicked' - | 'signInClicked'; + | 'signInClicked' + | 'reloadClicked'; interface IAgentSdkSetupFunnelEvent { agent: string; @@ -60,7 +61,7 @@ interface IAgentSdkSetupFunnelEvent { type AgentSdkSetupFunnelClassification = { agent: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent whose setup this step belongs to, e.g. claude or codex.' }; - step: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which step of the agent SDK setup funnel was reached (downloadOffered, downloadClicked, consentedDownload, noAccount, docsClicked, gitHubSignInClicked, signInClicked, resolved).' }; + step: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which step of the agent SDK setup funnel was reached (downloadOffered, downloadClicked, consentedDownload, noAccount, docsClicked, gitHubSignInClicked, signInClicked, reloadClicked, resolved).' }; owner: 'TylerLeonhardt'; comment: 'Tracks how far a signed-out user gets through setting up their own Claude or Codex account.'; }; @@ -81,6 +82,12 @@ export interface IAgentSdkSetupService { /** Open the setup instructions `agent` published, if it published any. */ openSetupDocs(agent: string): void; + /** + * Ask `agent` to look again at a setup the user completed outside the app — + * the only signal there is that a `claude login` in a terminal finished. + */ + requestReload(agent: string): void; + /** Start GitHub sign-in, which reaches every agent's models through our proxy. */ signInToGitHub(agent: string): void; @@ -174,6 +181,13 @@ class AgentSdkSetupService extends Disposable implements IAgentSdkSetupService { void this._openerService.open(url, { openExternal: true }); } + requestReload(agent: string): void { + this._reportStep(agent, 'reloadClicked'); + // Deliberately not a pending request: that set gates the download offer, and + // a reload happens in a state where there is nothing to offer. + this._dispatchRequest(AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, agent); + } + signInToGitHub(agent: string): void { // A thin wrapper over the ordinary Copilot sign-in, taking the agent id only // to attribute the click — which is the funnel's most telling drop. @@ -213,18 +227,22 @@ class AgentSdkSetupService extends Disposable implements IAgentSdkSetupService { private _dispatchDownloadRequest(agent: string): void { this._pendingRequests.add(agent); - // A fresh nonce every time so pressing the same button twice is two - // requests; the agent clears the key as it consumes it. - this._agentHostService.dispatch(ROOT_STATE_URI, { - type: ActionType.RootConfigChanged, - config: { [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request: generateUuid() } }, - }); + this._dispatchRequest(AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, agent); // The statuses are unchanged but {@link isDownloadPending} is not, and // without this the offer stays up until the host answers — the flicker the // pending set exists to prevent. this._onDidChangeSetups.fire(this._setups); } + private _dispatchRequest(key: string, agent: string): void { + // A fresh nonce every time so pressing the same thing twice is two + // requests; the agent clears the key as it consumes it. + this._agentHostService.dispatch(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [key]: { agent, request: generateUuid() } }, + }); + } + private _updateSetups(setups: readonly IAgentSdkSetupInfo[]): void { this._setups = setups; for (const setup of setups) { From 4b5f3abeb45d0f82a329ee337797d16577c1a48a Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:47:55 -0700 Subject: [PATCH 25/29] Move the scenario runner out of the MCP server (#331862) * Move the scenario runner out of the MCP server The validate-ui-scenario skill runs `runScenario`, which drives VS Code through `test/automation` and writes an evidence bundle. None of that is MCP: the runner loads no MCP module at runtime, and the SDK import it inherited was type-only, so TypeScript already elided it. It only lived under `test/mcp` because that is where the evidence pipeline was first written. That matters now: deleting the MCP server would take the skill with it. Move the six files that have nothing to do with MCP into a new `test/scenario` package, and leave `test/mcp` as one of its consumers alongside the skill. The MCP evidence tools move to `test/mcp/src/evidenceTools.ts`, where the server-specific schemas belong. Deleting `test/mcp` now removes only MCP code. Drop the step banner along with it. `showOverlay` appended a banner to the DOM of the product under test, which can shift layout and influence focus, so the runner always opted out via VSCODE_EVIDENCE_CLEAN_CAPTURE. With the runner as the only caller that opinion is unanimous, so the overlay and its opt-out both go and the capture is unconditionally faithful. Step titles are still rendered onto the finished recording by renderEvidenceChapters. The new package emits declarations, matching `test/automation`, so the MCP server keeps real types rather than silently degrading to `any`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Add the lockfile for the new scenario package Registering `test/scenario` in `build/npm/dirs.ts` makes the root install run npm in that directory, and CI uses `npm ci`, which requires a lockfile. Every other package registered there has one, so a clean CI install failed immediately with ENOENT on `test/scenario/package-lock.json` before anything compiled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Notify on the extracted scenario package `test/mcp/**` notifies @TylerLeonhardt, so moving the runner to `test/scenario` silently dropped notifications for it. Point the new path at the owner of the validate-ui-scenario skill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Do not re-declare @types/node in the scenario package The root package already declares `@types/node` as a devDependency, so the extracted package inherits it through normal ancestor resolution; declaring it again added a dependency that the OSS license check cannot cover, because `@types/node` ships no LICENSE file and is not in ClearlyDefined or cglicenses.json. Verified against the state CI produces: `npm ci` in `test/scenario` installs no `@types/node`, and both packages still compile, so the types resolve from the repository root as intended. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 --- .github/CODENOTIFY | 1 + .github/skills/validate-ui-scenario/SKILL.md | 8 +- build/npm/dirs.ts | 1 + eslint.config.js | 12 + test/mcp/package.json | 6 +- test/mcp/src/automation.ts | 4 +- test/mcp/src/automationTools/activityBar.ts | 2 +- test/mcp/src/automationTools/chat.ts | 2 +- test/mcp/src/automationTools/core.ts | 2 +- test/mcp/src/automationTools/debug.ts | 2 +- test/mcp/src/automationTools/editor.ts | 2 +- test/mcp/src/automationTools/explorer.ts | 2 +- test/mcp/src/automationTools/extensions.ts | 2 +- test/mcp/src/automationTools/index.ts | 2 +- test/mcp/src/automationTools/keybindings.ts | 2 +- test/mcp/src/automationTools/localization.ts | 2 +- test/mcp/src/automationTools/notebook.ts | 2 +- test/mcp/src/automationTools/problems.ts | 2 +- test/mcp/src/automationTools/profiler.ts | 2 +- test/mcp/src/automationTools/quickAccess.ts | 2 +- test/mcp/src/automationTools/scm.ts | 2 +- test/mcp/src/automationTools/search.ts | 2 +- test/mcp/src/automationTools/settings.ts | 2 +- test/mcp/src/automationTools/statusbar.ts | 2 +- test/mcp/src/automationTools/task.ts | 2 +- test/mcp/src/automationTools/terminal.ts | 2 +- test/mcp/src/automationTools/windows.ts | 2 +- test/mcp/src/evidenceTools.ts | 85 ++++++ test/mcp/src/stdio.ts | 3 +- test/scenario/.gitignore | 5 + test/scenario/package-lock.json | 247 ++++++++++++++++++ test/scenario/package.json | 20 ++ test/{mcp => scenario}/src/application.ts | 2 +- test/{mcp => scenario}/src/evidence.ts | 106 +------- test/scenario/src/index.ts | 11 + test/{mcp => scenario}/src/options.ts | 0 .../src/renderEvidenceChapters.ts | 0 test/{mcp => scenario}/src/runScenario.ts | 13 +- test/{mcp => scenario}/src/utils.ts | 0 test/scenario/tsconfig.json | 25 ++ 40 files changed, 444 insertions(+), 147 deletions(-) create mode 100644 test/mcp/src/evidenceTools.ts create mode 100644 test/scenario/.gitignore create mode 100644 test/scenario/package-lock.json create mode 100644 test/scenario/package.json rename test/{mcp => scenario}/src/application.ts (99%) rename test/{mcp => scenario}/src/evidence.ts (78%) create mode 100644 test/scenario/src/index.ts rename test/{mcp => scenario}/src/options.ts (100%) rename test/{mcp => scenario}/src/renderEvidenceChapters.ts (100%) rename test/{mcp => scenario}/src/runScenario.ts (91%) rename test/{mcp => scenario}/src/utils.ts (100%) create mode 100644 test/scenario/tsconfig.json diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index 3adf8957acda7..61b0a1756ac5d 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -83,6 +83,7 @@ extensions/vscode-api-tests/src/singlefolder-tests/browser*.test.ts @kycutler @j # Testing test/mcp/** @TylerLeonhardt +test/scenario/** @bryanchen-d test/sanity/** @dmitrivMS # Agents Workbench diff --git a/.github/skills/validate-ui-scenario/SKILL.md b/.github/skills/validate-ui-scenario/SKILL.md index 0288ef46749b5..16b002502f7d3 100644 --- a/.github/skills/validate-ui-scenario/SKILL.md +++ b/.github/skills/validate-ui-scenario/SKILL.md @@ -11,7 +11,7 @@ Use this to reproduce a reported bug, to show that a fix works, or to attach a r test-plan item. For deterministic regression coverage that runs on every build, write a smoke test instead (see the `smoke-tests` skill) — this skill is for one-off, issue-derived validation. -A scenario is a small JavaScript file run by `test/mcp/out/runScenario.js`. Nothing else has to be +A scenario is a small JavaScript file run by `test/scenario/out/runScenario.js`. Nothing else has to be configured: the runner launches VS Code, records video and a trace, captures a screenshot at every step boundary, writes the report, and captions the recording with each step and its result. @@ -19,7 +19,7 @@ step boundary, writes the report, and captions the recording with each step and ```bash npm install # once -npm --prefix test/mcp run compile # after any change under test/mcp +npm --prefix test/scenario run compile # after any change under test/scenario ``` Add `ffmpeg` and `ffprobe` to `PATH` to get the caption band on the video. Without them the run still @@ -130,7 +130,7 @@ Each step receives a `context` with `app`, `workbench`, `code`, `page`, and `ski ## Run it ```bash -node test/mcp/out/runScenario.js --build "" +node test/scenario/out/runScenario.js --build "" ``` Exit code `0` means every step passed, `1` means the run failed or was aborted, `2` a usage error. @@ -149,7 +149,7 @@ Evidence is written to `.build/vscode-playwright-mcp/evidence//`: The caption band is added **above** the recorded frame rather than drawn over it, so no recorded pixel is hidden and the recording keeps its original length. Each caption carries the step number and id, its status, the step title, and the validation detail the step reported. Re-render after -editing a manifest with `node test/mcp/out/renderEvidenceChapters.js `. +editing a manifest with `node test/scenario/out/renderEvidenceChapters.js `. ## What makes evidence trustworthy diff --git a/build/npm/dirs.ts b/build/npm/dirs.ts index 289a469754a5b..7bc33635f8681 100644 --- a/build/npm/dirs.ts +++ b/build/npm/dirs.ts @@ -59,6 +59,7 @@ export const dirs = [ 'test/integration/browser', 'test/monaco', 'test/smoke', + 'test/scenario', 'test/mcp', '.vscode/extensions/vscode-selfhost-import-aid', '.vscode/extensions/vscode-selfhost-test-provider', diff --git a/eslint.config.js b/eslint.config.js index 432f521df94c0..b9221655462a0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2329,10 +2329,22 @@ export default defineConfig( '*' // node modules ] }, + { + 'target': 'test/scenario/**', + 'restrictions': [ + 'test/automation', + 'test/scenario/**', + '@vscode/*', + '@parcel/*', + '@playwright/*', + '*' // node modules + ] + }, { 'target': 'test/mcp/**', 'restrictions': [ 'test/automation', + 'test/scenario', 'test/mcp/**', '@vscode/*', '@parcel/*', diff --git a/test/mcp/package.json b/test/mcp/package.json index bbddd2ed4fcf0..9938aa648a077 100644 --- a/test/mcp/package.json +++ b/test/mcp/package.json @@ -5,10 +5,10 @@ "main": "./out/main.js", "private": true, "scripts": { - "compile": "cd ../automation && npm run compile && cd ../mcp && node ../../node_modules/typescript/bin/tsc6", - "watch-automation": "cd ../automation && npm run watch", + "compile": "cd ../scenario && npm run compile && cd ../mcp && node ../../node_modules/typescript/bin/tsc6", + "watch-scenario": "cd ../scenario && npm run watch", "watch-mcp": "node ../../node_modules/typescript/bin/tsc6 --watch --preserveWatchOutput", - "watch": "npm-run-all2 -lp watch-automation watch-mcp", + "watch": "npm-run-all2 -lp watch-scenario watch-mcp", "start-stdio": "echo 'Starting vscode-automation-mcp... For customization and troubleshooting, see ./test/mcp/README.md' && npm ci && npm run -s compile && node ./out/stdio.js" }, "dependencies": { diff --git a/test/mcp/src/automation.ts b/test/mcp/src/automation.ts index ef2345e707f4c..97a070b98d321 100644 --- a/test/mcp/src/automation.ts +++ b/test/mcp/src/automation.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from './application'; +import { ApplicationService, EvidenceService } from '../../scenario'; import { applyAllTools } from './automationTools/index.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { z } from 'zod'; -import { applyEvidenceStartTool, applyEvidenceTools, EvidenceService } from './evidence.js'; +import { applyEvidenceStartTool, applyEvidenceTools } from './evidenceTools.js'; export async function getServer(appService: ApplicationService): Promise { const server = new McpServer({ diff --git a/test/mcp/src/automationTools/activityBar.ts b/test/mcp/src/automationTools/activityBar.ts index 8b06471dda933..e32553eecce26 100644 --- a/test/mcp/src/automationTools/activityBar.ts +++ b/test/mcp/src/automationTools/activityBar.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Activity Bar Tools diff --git a/test/mcp/src/automationTools/chat.ts b/test/mcp/src/automationTools/chat.ts index 5157bf19ddd88..aeecaf93a3d80 100644 --- a/test/mcp/src/automationTools/chat.ts +++ b/test/mcp/src/automationTools/chat.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/core.ts b/test/mcp/src/automationTools/core.ts index d81bc9570c457..dfc523ab2547b 100644 --- a/test/mcp/src/automationTools/core.ts +++ b/test/mcp/src/automationTools/core.ts @@ -5,7 +5,7 @@ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { ApplicationService, assertNoProfileOverrides } from '../application'; +import { ApplicationService, assertNoProfileOverrides } from '../../../scenario'; /** * Core Application Management Tools diff --git a/test/mcp/src/automationTools/debug.ts b/test/mcp/src/automationTools/debug.ts index b9dfb2951f3b5..268f209d515e2 100644 --- a/test/mcp/src/automationTools/debug.ts +++ b/test/mcp/src/automationTools/debug.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/editor.ts b/test/mcp/src/automationTools/editor.ts index 0af80f1865012..67436bbfd50a5 100644 --- a/test/mcp/src/automationTools/editor.ts +++ b/test/mcp/src/automationTools/editor.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/explorer.ts b/test/mcp/src/automationTools/explorer.ts index 777d7e3dfcb71..7f46d8107e62a 100644 --- a/test/mcp/src/automationTools/explorer.ts +++ b/test/mcp/src/automationTools/explorer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Explorer and File Management Tools diff --git a/test/mcp/src/automationTools/extensions.ts b/test/mcp/src/automationTools/extensions.ts index e379a7d90e4ee..40a54a2f8f2f4 100644 --- a/test/mcp/src/automationTools/extensions.ts +++ b/test/mcp/src/automationTools/extensions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/index.ts b/test/mcp/src/automationTools/index.ts index e5f595fb47fbd..0cd707451e649 100644 --- a/test/mcp/src/automationTools/index.ts +++ b/test/mcp/src/automationTools/index.ts @@ -26,7 +26,7 @@ import { applyTaskTools } from './task.js'; import { applyProfilerTools } from './profiler.js'; import { applyChatTools } from './chat.js'; import { applyWindowTools } from './windows.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Apply all VS Code automation tools to the MCP server diff --git a/test/mcp/src/automationTools/keybindings.ts b/test/mcp/src/automationTools/keybindings.ts index 28908119e0d68..8fec6893862e1 100644 --- a/test/mcp/src/automationTools/keybindings.ts +++ b/test/mcp/src/automationTools/keybindings.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Keybindings Editor Tools diff --git a/test/mcp/src/automationTools/localization.ts b/test/mcp/src/automationTools/localization.ts index bff17b43e7042..0b468655eb661 100644 --- a/test/mcp/src/automationTools/localization.ts +++ b/test/mcp/src/automationTools/localization.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Localization Tools diff --git a/test/mcp/src/automationTools/notebook.ts b/test/mcp/src/automationTools/notebook.ts index 68ce82ca6eba5..a157033ac0d9b 100644 --- a/test/mcp/src/automationTools/notebook.ts +++ b/test/mcp/src/automationTools/notebook.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/problems.ts b/test/mcp/src/automationTools/problems.ts index 4ff55b05270f8..88e82d184cabf 100644 --- a/test/mcp/src/automationTools/problems.ts +++ b/test/mcp/src/automationTools/problems.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/profiler.ts b/test/mcp/src/automationTools/profiler.ts index c47f8bb00cbbd..5852a5438f808 100644 --- a/test/mcp/src/automationTools/profiler.ts +++ b/test/mcp/src/automationTools/profiler.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Profiler Tools diff --git a/test/mcp/src/automationTools/quickAccess.ts b/test/mcp/src/automationTools/quickAccess.ts index b8f9c500c01ee..1b71ade39ba94 100644 --- a/test/mcp/src/automationTools/quickAccess.ts +++ b/test/mcp/src/automationTools/quickAccess.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/scm.ts b/test/mcp/src/automationTools/scm.ts index 55feec02b6902..ee6516cb5e237 100644 --- a/test/mcp/src/automationTools/scm.ts +++ b/test/mcp/src/automationTools/scm.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/search.ts b/test/mcp/src/automationTools/search.ts index 040ce94b68b5f..810dbba20af1e 100644 --- a/test/mcp/src/automationTools/search.ts +++ b/test/mcp/src/automationTools/search.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/settings.ts b/test/mcp/src/automationTools/settings.ts index 46f91fe8fbf7a..89d3cf8a5b39d 100644 --- a/test/mcp/src/automationTools/settings.ts +++ b/test/mcp/src/automationTools/settings.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/statusbar.ts b/test/mcp/src/automationTools/statusbar.ts index 2944d2c9faffc..e5c06f907924d 100644 --- a/test/mcp/src/automationTools/statusbar.ts +++ b/test/mcp/src/automationTools/statusbar.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Status Bar Tools diff --git a/test/mcp/src/automationTools/task.ts b/test/mcp/src/automationTools/task.ts index 270d596fc1855..82a871ae841ba 100644 --- a/test/mcp/src/automationTools/task.ts +++ b/test/mcp/src/automationTools/task.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/terminal.ts b/test/mcp/src/automationTools/terminal.ts index eaab27effdfc3..fd72d2a7ad251 100644 --- a/test/mcp/src/automationTools/terminal.ts +++ b/test/mcp/src/automationTools/terminal.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; import { z } from 'zod'; /** diff --git a/test/mcp/src/automationTools/windows.ts b/test/mcp/src/automationTools/windows.ts index 132cb2841c196..863027529dc74 100644 --- a/test/mcp/src/automationTools/windows.ts +++ b/test/mcp/src/automationTools/windows.ts @@ -5,7 +5,7 @@ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { ApplicationService } from '../application'; +import { ApplicationService } from '../../../scenario'; /** * Create a standardized text response for window tools diff --git a/test/mcp/src/evidenceTools.ts b/test/mcp/src/evidenceTools.ts new file mode 100644 index 0000000000000..f5c2caac62228 --- /dev/null +++ b/test/mcp/src/evidenceTools.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { EvidenceService, JSONValue } from '../../scenario'; + +const jsonValueSchema: z.ZodType = z.lazy(() => z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema) +])); + +function isHttpUrl(value: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(value).protocol); + } catch { + return false; + } +} + +export function applyEvidenceStartTool(server: McpServer, evidenceService: EvidenceService): RegisteredTool { + return server.tool( + 'vscode_automation_evidence_start', + 'Start VS Code with video and trace recording for a UI validation scenario', + { + scenarioId: z.string().describe('Stable scenario identifier'), + title: z.string().describe('Human-readable scenario title'), + source: z.string().url().refine(isHttpUrl, 'Source must use HTTP or HTTPS').optional().describe('Source test-plan issue URL'), + scenarioPath: z.string().optional().describe('Path to the Markdown scenario definition'), + workspacePath: z.string().optional().describe('Workspace or folder to open'), + userSettings: z.record(z.string(), jsonValueSchema).optional().describe('User settings to seed before VS Code starts'), + extraArgs: z.array(z.string()).optional().describe('Additional VS Code command-line arguments') + }, + async ({ scenarioId, title, source, scenarioPath, workspacePath, userSettings, extraArgs }) => { + const runPath = await evidenceService.start(scenarioId, title, source, scenarioPath, workspacePath, userSettings, extraArgs); + return { + content: [{ type: 'text' as const, text: `Evidence capture started: ${runPath}` }] + }; + } + ); +} + +export function applyEvidenceTools(server: McpServer, evidenceService: EvidenceService): RegisteredTool[] { + return [ + server.tool( + 'vscode_automation_evidence_step', + 'Mark a scenario step in the video and save a screenshot of the current VS Code window', + { + id: z.string().describe('Stable step identifier from the scenario'), + title: z.string().describe('Human-readable step title'), + status: z.enum(['started', 'passed', 'failed', 'skipped']).describe('Step lifecycle status'), + details: z.string().optional().describe('Validation result or failure details') + }, + async ({ id, title, status, details }) => { + const result = await evidenceService.step(id, title, status, details); + return { + content: [ + { type: 'text' as const, text: `Evidence saved: ${result.screenshotPath}` }, + { type: 'image' as const, data: result.screenshot.toString('base64'), mimeType: 'image/png' } + ] + }; + } + ), + server.tool( + 'vscode_automation_evidence_finish', + 'Finish a UI validation scenario, stop VS Code, and write the evidence report', + { + outcome: z.enum(['passed', 'failed', 'aborted']).describe('Overall scenario outcome'), + notes: z.string().optional().describe('Run summary or blocking condition') + }, + async ({ outcome, notes }) => { + const reportPath = await evidenceService.finish(outcome, notes); + return { + content: [{ type: 'text' as const, text: `Evidence report written: ${reportPath}` }] + }; + } + ) + ]; +} diff --git a/test/mcp/src/stdio.ts b/test/mcp/src/stdio.ts index ea8193baa2f33..e6c9a4bc27cb2 100644 --- a/test/mcp/src/stdio.ts +++ b/test/mcp/src/stdio.ts @@ -4,8 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { getServer } from './automation'; -import { ApplicationService } from './application'; -import { opts } from './options'; +import { ApplicationService, opts } from '../../scenario'; const transport: StdioServerTransport = new StdioServerTransport(); (async () => { diff --git a/test/scenario/.gitignore b/test/scenario/.gitignore new file mode 100644 index 0000000000000..e7d563c46ad50 --- /dev/null +++ b/test/scenario/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +npm-debug.log +Thumbs.db +node_modules/ +out/ diff --git a/test/scenario/package-lock.json b/test/scenario/package-lock.json new file mode 100644 index 0000000000000..afafe0902c100 --- /dev/null +++ b/test/scenario/package-lock.json @@ -0,0 +1,247 @@ +{ + "name": "vscode-scenario-runner", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-scenario-runner", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8" + }, + "devDependencies": { + "npm-run-all2": "^8.0.4" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-all2": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", + "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "picomatch": "^4.0.2", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^20.5.0 || >=22.0.0", + "npm": ">= 10" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", + "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + } + } +} diff --git a/test/scenario/package.json b/test/scenario/package.json new file mode 100644 index 0000000000000..d6f2c905bb9ab --- /dev/null +++ b/test/scenario/package.json @@ -0,0 +1,20 @@ +{ + "name": "vscode-scenario-runner", + "version": "0.1.0", + "description": "Runs UI validation scenarios against VS Code and records evidence", + "license": "MIT", + "main": "./out/index.js", + "private": true, + "scripts": { + "compile": "cd ../automation && npm run compile && cd ../scenario && node ../../node_modules/typescript/bin/tsc6", + "watch-automation": "cd ../automation && npm run watch", + "watch-scenario": "node ../../node_modules/typescript/bin/tsc6 --watch --preserveWatchOutput", + "watch": "npm-run-all2 -lp watch-automation watch-scenario" + }, + "dependencies": { + "minimist": "^1.2.8" + }, + "devDependencies": { + "npm-run-all2": "^8.0.4" + } +} diff --git a/test/mcp/src/application.ts b/test/scenario/src/application.ts similarity index 99% rename from test/mcp/src/application.ts rename to test/scenario/src/application.ts index 9bfb2e8241c16..dfbe0e00d72de 100644 --- a/test/mcp/src/application.ts +++ b/test/scenario/src/application.ts @@ -238,7 +238,7 @@ async function removeProfileData(userDataPath: string | undefined): Promise | undefined, web: boolean): Promise { if (!userDataDir) { - throw new Error('Cannot pre-seed the MCP test profile without a user data directory.'); + throw new Error('Cannot pre-seed the isolated test profile without a user data directory.'); } const userDir = path.join(userDataDir, ...(web ? ['data', 'User'] : ['User'])); diff --git a/test/mcp/src/evidence.ts b/test/scenario/src/evidence.ts similarity index 78% rename from test/mcp/src/evidence.ts rename to test/scenario/src/evidence.ts index aa124aa0e1f02..d2a3f3c029d68 100644 --- a/test/mcp/src/evidence.ts +++ b/test/scenario/src/evidence.ts @@ -3,11 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Page } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; -import { z } from 'zod'; import { ApplicationService, getProductVersion, JSONValue } from './application'; const rootPath = path.join(__dirname, '..', '..', '..'); @@ -16,16 +14,8 @@ const evidenceRootPath = path.join(artifactRootPath, 'evidence'); const logsRootPath = path.join(artifactRootPath, 'logs'); const qualityNames = ['Dev', 'Insiders', 'Stable', 'Exploration', 'OSS']; -type StepStatus = 'started' | 'passed' | 'failed' | 'skipped'; -type RunOutcome = 'passed' | 'failed' | 'aborted'; -const jsonValueSchema: z.ZodType = z.lazy(() => z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.array(jsonValueSchema), - z.record(z.string(), jsonValueSchema) -])); +export type StepStatus = 'started' | 'passed' | 'failed' | 'skipped'; +export type RunOutcome = 'passed' | 'failed' | 'aborted'; interface EvidenceCapture { status: StepStatus; @@ -162,7 +152,6 @@ export class EvidenceService { app.code.driver.browserContext.on('page', run.pageListener); await app.startTracing(); - await this.showOverlay('Scenario', title, 'started'); await wait(500); await this.capture('00-scenario-started.png'); this.writeManifest(); @@ -233,7 +222,6 @@ export class EvidenceService { let screenshot: Buffer | undefined; for (let attempt = 0; attempt < 2; attempt++) { try { - await this.showOverlay(id, title, status); await wait(status === 'started' ? 500 : 250); const sequence = String(run.steps.indexOf(step) + 1).padStart(2, '0'); screenshotName = `${sequence}-${sanitizePathSegment(id)}-${status}.png`; @@ -307,7 +295,6 @@ export class EvidenceService { if (app) { for (let attempt = 0; attempt < 2; attempt++) { try { - await this.showOverlay('Result', run.title, outcome); await wait(500); await this.capture(`99-result-${outcome}.png`); break; @@ -389,38 +376,11 @@ export class EvidenceService { private requireRun(): EvidenceRun { if (!this.currentRun) { - throw new Error('No evidence run is active. Start one with vscode_automation_evidence_start.'); + throw new Error('No evidence run is active. Start one before recording steps.'); } return this.currentRun; } - private async showOverlay(id: string, title: string, status: string): Promise { - if (process.env.VSCODE_EVIDENCE_CLEAN_CAPTURE === '1') { - // The overlay is appended to the DOM of the product under test, so it can - // shift layout and influence focus or selectors. Callers that annotate the - // recording afterwards opt out to keep the capture faithful. - return; - } - const app = this.appService.application; - if (!app) { - throw new Error('VS Code is not running.'); - } - const values = JSON.stringify({ id, title, status }); - await app.code.driver.evaluateExpression(`(() => { - const values = ${values}; - let overlay = document.getElementById('vscode-ui-evidence-overlay'); - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = 'vscode-ui-evidence-overlay'; - overlay.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:70vw;padding:10px 16px;border-radius:6px;background:rgba(0,0,0,.88);color:#fff;font:600 14px/1.4 system-ui;box-shadow:0 4px 18px rgba(0,0,0,.35);pointer-events:none;text-align:center'; - document.documentElement.appendChild(overlay); - } - overlay.textContent = values.id + ': ' + values.title + ' [' + values.status.toUpperCase() + ']'; - overlay.dataset.status = values.status; - return overlay.textContent; - })()`); - } - private async capture(name: string): Promise { const run = this.requireRun(); const app = this.appService.application; @@ -513,66 +473,6 @@ export class EvidenceService { } -export function applyEvidenceStartTool(server: McpServer, evidenceService: EvidenceService): RegisteredTool { - return server.tool( - 'vscode_automation_evidence_start', - 'Start VS Code with video and trace recording for a UI validation scenario', - { - scenarioId: z.string().describe('Stable scenario identifier'), - title: z.string().describe('Human-readable scenario title'), - source: z.string().url().refine(isHttpUrl, 'Source must use HTTP or HTTPS').optional().describe('Source test-plan issue URL'), - scenarioPath: z.string().optional().describe('Path to the Markdown scenario definition'), - workspacePath: z.string().optional().describe('Workspace or folder to open'), - userSettings: z.record(z.string(), jsonValueSchema).optional().describe('User settings to seed before VS Code starts'), - extraArgs: z.array(z.string()).optional().describe('Additional VS Code command-line arguments') - }, - async ({ scenarioId, title, source, scenarioPath, workspacePath, userSettings, extraArgs }) => { - const runPath = await evidenceService.start(scenarioId, title, source, scenarioPath, workspacePath, userSettings, extraArgs); - return { - content: [{ type: 'text' as const, text: `Evidence capture started: ${runPath}` }] - }; - } - ); -} - -export function applyEvidenceTools(server: McpServer, evidenceService: EvidenceService): RegisteredTool[] { - return [ - server.tool( - 'vscode_automation_evidence_step', - 'Mark a scenario step in the video and save a screenshot of the current VS Code window', - { - id: z.string().describe('Stable step identifier from the scenario'), - title: z.string().describe('Human-readable step title'), - status: z.enum(['started', 'passed', 'failed', 'skipped']).describe('Step lifecycle status'), - details: z.string().optional().describe('Validation result or failure details') - }, - async ({ id, title, status, details }) => { - const result = await evidenceService.step(id, title, status, details); - return { - content: [ - { type: 'text' as const, text: `Evidence saved: ${result.screenshotPath}` }, - { type: 'image' as const, data: result.screenshot.toString('base64'), mimeType: 'image/png' } - ] - }; - } - ), - server.tool( - 'vscode_automation_evidence_finish', - 'Finish a UI validation scenario, stop VS Code, and write the evidence report', - { - outcome: z.enum(['passed', 'failed', 'aborted']).describe('Overall scenario outcome'), - notes: z.string().optional().describe('Run summary or blocking condition') - }, - async ({ outcome, notes }) => { - const reportPath = await evidenceService.finish(outcome, notes); - return { - content: [{ type: 'text' as const, text: `Evidence report written: ${reportPath}` }] - }; - } - ) - ]; -} - function sanitizePathSegment(value: string): string { const sanitized = value.trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); return sanitized || 'unnamed'; diff --git a/test/scenario/src/index.ts b/test/scenario/src/index.ts new file mode 100644 index 0000000000000..f8a985856c7ac --- /dev/null +++ b/test/scenario/src/index.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from './application'; +export * from './evidence'; +export * from './options'; +export * from './renderEvidenceChapters'; +export * from './runScenario'; +export * from './utils'; diff --git a/test/mcp/src/options.ts b/test/scenario/src/options.ts similarity index 100% rename from test/mcp/src/options.ts rename to test/scenario/src/options.ts diff --git a/test/mcp/src/renderEvidenceChapters.ts b/test/scenario/src/renderEvidenceChapters.ts similarity index 100% rename from test/mcp/src/renderEvidenceChapters.ts rename to test/scenario/src/renderEvidenceChapters.ts diff --git a/test/mcp/src/runScenario.ts b/test/scenario/src/runScenario.ts similarity index 91% rename from test/mcp/src/runScenario.ts rename to test/scenario/src/runScenario.ts index 697fb9b54c4c5..aa45a1b72173b 100644 --- a/test/mcp/src/runScenario.ts +++ b/test/scenario/src/runScenario.ts @@ -13,12 +13,8 @@ import { renderChapters } from './renderEvidenceChapters'; /** * Runs a UI validation scenario end to end and writes an evidence bundle. * - * This is the same capture pipeline the `vscode_automation_evidence_*` MCP tools - * drive, exposed as a single command so a scenario can be recorded without - * configuring an MCP server: - * * ``` - * node test/mcp/out/runScenario.js [--build ] + * node test/scenario/out/runScenario.js [--build ] * ``` * * The scenario file is not part of this repository, so it can be written next to @@ -99,11 +95,6 @@ function loadScenario(scenarioPath: string): Scenario { } export async function runScenario(scenario: Scenario): Promise<{ runPath: string; outcome: 'passed' | 'failed' | 'aborted' }> { - // The step banner is drawn into the DOM of the product under test, so it can - // shift layout and influence focus. Chapters are rendered onto the finished - // recording instead, which keeps the capture faithful. - process.env.VSCODE_EVIDENCE_CLEAN_CAPTURE ??= '1'; - const appService = new ApplicationService(); const evidence = new EvidenceService(appService); const runPath = await evidence.start( @@ -167,7 +158,7 @@ export async function runScenario(scenario: Scenario): Promise<{ runPath: string if (require.main === module) { const scenarioArgument = process.argv.slice(2).find(argument => !argument.startsWith('--')); if (!scenarioArgument) { - console.error('Usage: node test/mcp/out/runScenario.js [--build ]'); + console.error('Usage: node test/scenario/out/runScenario.js [--build ]'); process.exit(2); } const scenarioPath = path.resolve(scenarioArgument); diff --git a/test/mcp/src/utils.ts b/test/scenario/src/utils.ts similarity index 100% rename from test/mcp/src/utils.ts rename to test/scenario/src/utils.ts diff --git a/test/scenario/tsconfig.json b/test/scenario/tsconfig.json new file mode 100644 index 0000000000000..5341c14f225e7 --- /dev/null +++ b/test/scenario/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": false, + "removeComments": false, + "preserveConstEnums": true, + "target": "es2024", + "strict": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "rootDir": "./src", + "outDir": "out", + "sourceMap": true, + "declaration": true, + "skipLibCheck": true, + "lib": [ + "esnext", + "dom" + ] + }, + "exclude": [ + "node_modules", + "out" + ] +} From fea32a81fab2630d7f8ae32eaa44c07201eca013 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 20 Aug 2026 14:53:34 -0700 Subject: [PATCH 26/29] Avoid forced layout when measuring the Chat view title (#331846) * chat: observe view title height Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: use disposable title resize observer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.ts | 3 +- .../viewPane/chatViewTitleControl.ts | 29 +++---- .../viewPane/chatViewTitleControl.test.ts | 75 +++++++++++++++++++ 3 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewTitleControl.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index ec7bf02168ff4..53f072d7d1192 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -1124,7 +1124,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { parent, { focusChat: () => this._widget.focusInput() - } + }, + undefined )); this._register(this.titleControl.onDidChangeHeight(() => { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts index b60a4f3b64f39..ad34f213d3429 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatViewTitleControl.css'; -import { addDisposableListener, EventType, h } from '../../../../../../base/browser/dom.js'; +import { addDisposableListener, DisposableResizeObserver, EventType, getWindow, h } from '../../../../../../base/browser/dom.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { Gesture, EventType as TouchEventType } from '../../../../../../base/browser/touch.js'; import { Emitter } from '../../../../../../base/common/event.js'; @@ -49,11 +49,25 @@ export class ChatViewTitleControl extends Disposable { constructor( private readonly container: HTMLElement, private readonly delegate: IChatViewTitleDelegate, + resizeObserverCtor: typeof ResizeObserver | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); this.render(this.container); + // Avoid forcing layout; ResizeObserver reports the final size before paint and triggers relayout. + const resizeObserver = this._register(new DisposableResizeObserver('ChatViewTitleControl.height', entries => { + const entry = entries.find(entry => entry.target === this.titleContainer); + if (!entry) { + return; + } + const height = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height; + if (height !== this.lastKnownHeight) { + this.lastKnownHeight = height; + this._onDidChangeHeight.fire(); + } + }, getWindow(this.titleContainer!), { resizeObserverCtor })); + this._register(resizeObserver.observe(this.titleContainer!, { box: 'border-box' })); this.registerActions(); } @@ -165,13 +179,6 @@ export class ChatViewTitleControl extends Disposable { this.titleContainer.classList.toggle('visible', this.shouldRender()); this.titleLabel.value?.updateTitle(title); - - const currentHeight = this.getHeight(); - if (currentHeight !== this.lastKnownHeight) { - this.lastKnownHeight = currentHeight; - - this._onDidChangeHeight.fire(); - } } private shouldRender(): boolean { @@ -179,11 +186,7 @@ export class ChatViewTitleControl extends Disposable { } getHeight(): number { - if (!this.titleContainer || this.titleContainer.style.display === 'none') { - return 0; - } - - return this.titleContainer.offsetHeight; + return this.lastKnownHeight; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewTitleControl.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewTitleControl.test.ts new file mode 100644 index 0000000000000..d1aa7fc0655c1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewTitleControl.test.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MenuWorkbenchToolBar } from '../../../../../../../platform/actions/browser/toolbar.js'; +import { ChatViewTitleControl } from '../../../../browser/widgetHosts/viewPane/chatViewTitleControl.js'; + +class TestResizeObserver implements ResizeObserver { + static instance: TestResizeObserver | undefined; + private observedTarget: Element | undefined; + observedBox: ResizeObserverBoxOptions | undefined; + + constructor(private readonly callback: ResizeObserverCallback) { + TestResizeObserver.instance = this; + } + + observe(target: Element, options?: ResizeObserverOptions): void { + this.observedTarget = target; + this.observedBox = options?.box; + } + + unobserve(): void { } + disconnect(): void { } + takeRecords(): ResizeObserverEntry[] { return []; } + + fire(height: number): void { + assert.ok(this.observedTarget); + const size: ResizeObserverSize = { inlineSize: 0, blockSize: height }; + this.callback([{ + target: this.observedTarget, + contentRect: DOMRectReadOnly.fromRect({ height }), + borderBoxSize: [size], + contentBoxSize: [size], + devicePixelContentBoxSize: [size], + }], this); + } +} + +suite('ChatViewTitleControl', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks height changes from ResizeObserver', () => { + const container = document.createElement('div'); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(MenuWorkbenchToolBar, { dispose: () => { } }); + const control = disposables.add(instantiationService.createInstance( + ChatViewTitleControl, + container, + { focusChat: () => { } }, + TestResizeObserver + )); + const resizeObserver = TestResizeObserver.instance; + assert.ok(resizeObserver); + const observedHeights: number[] = []; + disposables.add(control.onDidChangeHeight(() => observedHeights.push(control.getHeight()))); + + resizeObserver.fire(22); + resizeObserver.fire(22); + resizeObserver.fire(0); + + assert.deepStrictEqual({ + height: control.getHeight(), + observedHeights, + observedBox: resizeObserver.observedBox + }, { + height: 0, + observedHeights: [22, 0], + observedBox: 'border-box' + }); + }); +}); From 70af9b11638cbb4e4b4ec997916510852cdd61c8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:01:56 -0700 Subject: [PATCH 27/29] Keep context menus above shadow DOM hosts (#323776) * Initial plan * Fix shadow DOM context menu layering Co-authored-by: amunger <2019016+amunger@users.noreply.github.com> * Fix notebook context menu layering Mount shadow DOM context views at the workbench container so notebook-local stacking contexts cannot obscure them behind adjacent parts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Scope shadow context menu root mounting Mount fixed-overflow editor context menus at their window root so notebook menus can escape local stacking contexts, while preserving explicit local shadow-root containers such as menuAsChild. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: amunger <2019016+amunger@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/contextmenu.ts | 5 ++ .../browser/ui/contextview/contextview.ts | 13 ++++- .../ui/contextview/contextview.test.ts | 34 +++++++++++ .../contextmenu/browser/contextmenu.ts | 2 + .../standalone/browser/standaloneServices.ts | 4 +- .../contextview/browser/contextMenuHandler.ts | 2 +- .../contextview/browser/contextView.ts | 2 +- .../contextview/browser/contextViewService.ts | 5 +- .../test/browser/contextViewService.test.ts | 58 +++++++++++++++++++ 9 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 src/vs/workbench/test/browser/contextViewService.test.ts diff --git a/src/vs/base/browser/contextmenu.ts b/src/vs/base/browser/contextmenu.ts index f8d7707b8ae4e..895bf2c923298 100644 --- a/src/vs/base/browser/contextmenu.ts +++ b/src/vs/base/browser/contextmenu.ts @@ -47,6 +47,11 @@ export interface IContextMenuDelegate { anchorAlignment?: AnchorAlignment; anchorAxisAlignment?: AnchorAxisAlignment; domForShadowRoot?: HTMLElement; + /** + * Mount the shadow root in the window container instead of `domForShadowRoot`. + * Use this when the provided element is inside a stacking context that cannot contain the menu. + */ + useWindowContainerForShadowRoot?: boolean; /** * custom context menus with higher layers are rendered higher in z-index order */ diff --git a/src/vs/base/browser/ui/contextview/contextview.ts b/src/vs/base/browser/ui/contextview/contextview.ts index 9d50417de5189..e8e645456467d 100644 --- a/src/vs/base/browser/ui/contextview/contextview.ts +++ b/src/vs/base/browser/ui/contextview/contextview.ts @@ -248,6 +248,13 @@ export class ContextView extends Disposable { if (this.useShadowDOM) { this.shadowRootHostElement = DOM.$('.shadow-root-host'); + Object.assign(this.shadowRootHostElement.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0' + }); this.container.appendChild(this.shadowRootHostElement); this.shadowRoot = this.shadowRootHostElement.attachShadow({ mode: 'open' }); const style = document.createElement('style'); @@ -289,7 +296,11 @@ export class ContextView extends Disposable { this.view.className = 'context-view monaco-component'; this.view.style.top = '0px'; this.view.style.left = '0px'; - this.view.style.zIndex = `${2575 + (delegate.layer ?? 0)}`; + const zIndex = `${2575 + (delegate.layer ?? 0)}`; + this.view.style.zIndex = zIndex; + if (this.shadowRootHostElement) { + this.shadowRootHostElement.style.zIndex = zIndex; + } this.view.style.position = this.useFixedPosition ? 'fixed' : 'absolute'; DOM.show(this.view); diff --git a/src/vs/base/test/browser/ui/contextview/contextview.test.ts b/src/vs/base/test/browser/ui/contextview/contextview.test.ts index 2d8fd72ab0cd5..2b5fa82f0049c 100644 --- a/src/vs/base/test/browser/ui/contextview/contextview.test.ts +++ b/src/vs/base/test/browser/ui/contextview/contextview.test.ts @@ -44,6 +44,40 @@ suite('ContextView', () => { container.remove(); }); + test('shadow DOM host is layered with the context view', () => { + const container = $('.container'); + const contextView = new ContextView(container, ContextViewDOMPosition.FIXED_SHADOW); + const delegate: IDelegate = { + getAnchor: () => ({ x: 0, y: 0 }), + render: () => null, + layer: 1 + }; + + contextView.show(delegate); + + const shadowRootHost = container.getElementsByClassName('shadow-root-host')[0] as HTMLElement; + assert.deepStrictEqual({ + position: shadowRootHost.style.position, + top: shadowRootHost.style.top, + left: shadowRootHost.style.left, + width: shadowRootHost.style.width, + height: shadowRootHost.style.height, + zIndex: shadowRootHost.style.zIndex, + contextViewZIndex: contextView.getViewElement().style.zIndex + }, { + position: 'fixed', + top: '0px', + left: '0px', + width: '0px', + height: '0px', + zIndex: '2576', + contextViewZIndex: '2576' + }); + + contextView.dispose(); + container.remove(); + }); + test('hide() delays render disposal for close animations', () => { const clock = sinon.useFakeTimers(); const container = $('.container'); diff --git a/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts b/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts index 6bcb8223428ab..830f8668f9473 100644 --- a/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts +++ b/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts @@ -218,6 +218,7 @@ export class ContextMenuController implements IEditorContribution { this._contextMenuIsBeingShownCount++; this._contextMenuService.showContextMenu({ domForShadowRoot: useShadowDOM ? this._editor.getOverflowWidgetsDomNode() ?? this._editor.getDomNode() : undefined, + useWindowContainerForShadowRoot: useShadowDOM && this._editor.getOption(EditorOption.fixedOverflowWidgets), getAnchor: () => anchor, @@ -366,6 +367,7 @@ export class ContextMenuController implements IEditorContribution { this._contextMenuIsBeingShownCount++; this._contextMenuService.showContextMenu({ domForShadowRoot: useShadowDOM ? this._editor.getDomNode() : undefined, + useWindowContainerForShadowRoot: useShadowDOM && this._editor.getOption(EditorOption.fixedOverflowWidgets), getAnchor: () => anchor, getActions: () => actions, onHide: (wasCancelled: boolean) => { diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index ed9ff06a2e298..fcfbab8c0241d 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -1005,14 +1005,14 @@ class StandaloneContextViewService extends ContextViewService { super(layoutService); } - override showContextView(delegate: IContextViewDelegate, container?: HTMLElement, shadowRoot?: boolean): IOpenContextView { + override showContextView(delegate: IContextViewDelegate, container?: HTMLElement, shadowRoot?: boolean, useWindowContainerForShadowRoot?: boolean): IOpenContextView { if (!container) { const codeEditor = this._codeEditorService.getFocusedCodeEditor() || this._codeEditorService.getActiveCodeEditor(); if (codeEditor) { container = codeEditor.getContainerDomNode(); } } - return super.showContextView(delegate, container, shadowRoot); + return super.showContextView(delegate, container, shadowRoot, useWindowContainerForShadowRoot); } } diff --git a/src/vs/platform/contextview/browser/contextMenuHandler.ts b/src/vs/platform/contextview/browser/contextMenuHandler.ts index d1b62abba07b9..e4be42a0667d2 100644 --- a/src/vs/platform/contextview/browser/contextMenuHandler.ts +++ b/src/vs/platform/contextview/browser/contextMenuHandler.ts @@ -146,7 +146,7 @@ export class ContextMenuHandler { this.lastContainer = null; } - }, shadowRootElement, !!shadowRootElement); + }, shadowRootElement, !!shadowRootElement, delegate.useWindowContainerForShadowRoot); } private onActionRun(e: IRunEvent, logTelemetry: boolean): void { diff --git a/src/vs/platform/contextview/browser/contextView.ts b/src/vs/platform/contextview/browser/contextView.ts index c6cf7a57d4cb1..1264dc4ae07ca 100644 --- a/src/vs/platform/contextview/browser/contextView.ts +++ b/src/vs/platform/contextview/browser/contextView.ts @@ -20,7 +20,7 @@ export interface IContextViewService extends IContextViewProvider { readonly _serviceBrand: undefined; - showContextView(delegate: IContextViewDelegate, container?: HTMLElement, shadowRoot?: boolean): IOpenContextView; + showContextView(delegate: IContextViewDelegate, container?: HTMLElement, shadowRoot?: boolean, useWindowContainerForShadowRoot?: boolean): IOpenContextView; hideContextView(data?: any): void; getContextViewElement(): HTMLElement; layout(): void; diff --git a/src/vs/platform/contextview/browser/contextViewService.ts b/src/vs/platform/contextview/browser/contextViewService.ts index 1ca549dc76c68..02d22e758405c 100644 --- a/src/vs/platform/contextview/browser/contextViewService.ts +++ b/src/vs/platform/contextview/browser/contextViewService.ts @@ -27,7 +27,7 @@ export class ContextViewHandler extends Disposable implements IContextViewProvid // ContextView - showContextView(delegate: IContextViewDelegate, container?: HTMLElement, shadowRoot?: boolean): IOpenContextView { + showContextView(delegate: IContextViewDelegate, container?: HTMLElement, shadowRoot?: boolean, useWindowContainerForShadowRoot?: boolean): IOpenContextView { let domPosition: ContextViewDOMPosition; if (container) { if (container === this.layoutService.getContainer(getWindow(container))) { @@ -41,7 +41,8 @@ export class ContextViewHandler extends Disposable implements IContextViewProvid domPosition = ContextViewDOMPosition.ABSOLUTE; } - this.contextView.setContainer(container ?? this.layoutService.activeContainer, domPosition); + const contextViewContainer = useWindowContainerForShadowRoot && container ? this.layoutService.getContainer(getWindow(container)) : container ?? this.layoutService.activeContainer; + this.contextView.setContainer(contextViewContainer, domPosition); this.contextView.show(delegate); diff --git a/src/vs/workbench/test/browser/contextViewService.test.ts b/src/vs/workbench/test/browser/contextViewService.test.ts new file mode 100644 index 0000000000000..6e59ba344b7b4 --- /dev/null +++ b/src/vs/workbench/test/browser/contextViewService.test.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { $, isShadowRoot } from '../../../base/browser/dom.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { ContextViewService } from '../../../platform/contextview/browser/contextViewService.js'; +import { TestLayoutService } from './workbenchTestServices.js'; + +suite('ContextViewService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('mounts requested shadow roots in the window container', () => { + const windowContainer = $('.window-container'); + const localContainer = $('.local-container'); + windowContainer.appendChild(localContainer); + document.body.appendChild(windowContainer); + disposables.add(toDisposable(() => windowContainer.remove())); + + class TestWindowLayoutService extends TestLayoutService { + override mainContainer = windowContainer; + override activeContainer = windowContainer; + override containers = [windowContainer]; + + override getContainer(): HTMLElement { + return windowContainer; + } + } + + const service = disposables.add(new ContextViewService(new TestWindowLayoutService())); + const delegate = { + getAnchor: () => ({ x: 0, y: 0 }), + render: () => Disposable.None + }; + + service.showContextView(delegate, localContainer, true, true); + const windowMountedHost = service.getContextViewElement().getRootNode(); + assert.ok(isShadowRoot(windowMountedHost)); + const windowMountedAtRoot = windowMountedHost.host.parentElement === windowContainer; + + service.hideContextView(); + service.showContextView(delegate, localContainer, true); + const locallyMountedHost = service.getContextViewElement().getRootNode(); + assert.ok(isShadowRoot(locallyMountedHost)); + + assert.deepStrictEqual({ + windowMountedAtRoot, + locallyMounted: locallyMountedHost.host.parentElement === localContainer + }, { + windowMountedAtRoot: true, + locallyMounted: true + }); + + }); +}); From c90d9c775629dbc731d2c38dd3c7e4e9828ce7c0 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:08:08 -0700 Subject: [PATCH 28/29] agentHost: Use canonical Copilot telemetry provider (#330964) * agentHost: fix Copilot telemetry provider Use the session URI scheme for tool-call and tool-approval compatibility events so Agent Host rows consistently report the canonical provider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: handle peer chat telemetry providers Derive compatibility-event providers from the owning session so peer chat resources do not report the ahp-chat scheme. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotAgentSession.ts | 4 ++-- .../test/node/copilotAgentSession.test.ts | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f486cc80cdbaf..19fcb12d5f373 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1432,7 +1432,7 @@ export class CopilotAgentSession extends Disposable { turn.toolCallDetailsReported = true; void this._telemetryReporter.toolCallDetails({ clientContext: turn.clientContext, - provider: 'copilot', + provider: this._ownerSessionUri.scheme, session: this.resourceUri.toString(), turnId: turn.id, clientType: turn.clientType, @@ -1458,7 +1458,7 @@ export class CopilotAgentSession extends Disposable { const confirmKind = mapPermissionResultToConfirmKind(record?.resultKind, record?.resolvedByHook === true); this._telemetryReporter.toolApproval({ clientContext: this._currentTurn?.clientContext, - provider: 'copilot', + provider: this._ownerSessionUri.scheme, session: this.resourceUri.toString(), turnId: this._turnId, toolId: toolName, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 387e76e2d5966..9c3945f0d7db1 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -6504,8 +6504,13 @@ suite('CopilotAgentSession', () => { test('tool-call aggregate emits once with cancelled result across abort and idle', async () => { const telemetryService = new CapturingTelemetryService(); + const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); + const peerChatUri = URI.parse(buildChatUri(sessionUri, 'peer-1')); const { session, mockSession, signals } = await createAgentSession(disposables, { telemetryService, + sessionUri, + chatChannelUri: peerChatUri, + resource: peerChatUri, clientSnapshot: { tools: [{ name: 'grep' }, { name: 'edit' }], plugins: [], mcpServers: {} }, }); session.resetTurnState('turn-tool-details'); @@ -6556,7 +6561,7 @@ suite('CopilotAgentSession', () => { }, { telemetry: [{ eventName: 'toolCallDetails', - provider: 'copilot', + provider: 'copilotcli', requestId: 'turn-tool-details', responseType: 'cancelled', toolCounts: JSON.stringify({ grep: 1, edit: 1 }), @@ -6612,7 +6617,14 @@ suite('CopilotAgentSession', () => { test('tool approval waits for permission outcome and falls back only at completion', async () => { const telemetryService = new CapturingTelemetryService(); - const { session, mockSession } = await createAgentSession(disposables, { telemetryService }); + const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); + const peerChatUri = URI.parse(buildChatUri(sessionUri, 'peer-1')); + const { session, mockSession } = await createAgentSession(disposables, { + telemetryService, + sessionUri, + chatChannelUri: peerChatUri, + resource: peerChatUri, + }); session.resetTurnState('turn-approval'); mockSession.fire('tool.execution_start', { @@ -6653,16 +6665,17 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'chat.toolApproval').map(event => { const data = event.data as Record; return { + provider: data.provider, toolId: data.toolId, confirmKind: data.confirmKind, confirmationNotNeededReason: data.confirmationNotNeededReason, }; }), [{ - toolId: 'bash', confirmKind: 'userAction', confirmationNotNeededReason: undefined, + provider: 'copilotcli', toolId: 'bash', confirmKind: 'userAction', confirmationNotNeededReason: undefined, }, { - toolId: 'edit', confirmKind: 'denied', confirmationNotNeededReason: undefined, + provider: 'copilotcli', toolId: 'edit', confirmKind: 'denied', confirmationNotNeededReason: undefined, }, { - toolId: 'grep', confirmKind: 'confirmationNotNeeded', confirmationNotNeededReason: undefined, + provider: 'copilotcli', toolId: 'grep', confirmKind: 'confirmationNotNeeded', confirmationNotNeededReason: undefined, }]); }); From 7edc21d46dfbb25122829d68c7844b3b05399fa2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 21 Aug 2026 00:08:55 +0200 Subject: [PATCH 29/29] sessions: Respect metadata pill placement in chat tabs (#331870) * sessions: respect metadata pill placement in tabs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: gate tabs header presentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/chatCompositeBar.ts | 10 +++++++- .../sessions/browser/parts/chatGroupsView.ts | 7 +++++- .../browser/parts/media/chatCompositeBar.css | 4 ++-- src/vs/sessions/browser/parts/sessionView.ts | 13 ++++++++-- .../test/browser/chatCompositeBar.test.ts | 24 ++++++++++++++++--- .../test/browser/chatGroupsView.test.ts | 12 +++++++++- 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index d128c870b9354..5784b43920bac 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -44,6 +44,9 @@ import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js' import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { getSessionConversationStatusAriaLabel } from '../sessionConversationGroups.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; interface IChatTab { readonly chat: IChat; @@ -114,6 +117,7 @@ export class ChatCompositeBar extends Disposable { private readonly _sessionToolbar: MenuWorkbenchToolBar; private readonly _metaRow: HTMLElement; private readonly _metaToolbar: MenuWorkbenchToolBar; + private readonly _showMetadataInChatInput: IObservable; private readonly _tabs: IChatTab[] = []; private readonly _tabDisposables = this._register(new DisposableStore()); @@ -122,6 +126,7 @@ export class ChatCompositeBar extends Disposable { private _editingTab: IChatTab | undefined; private _delegate: IChatCompositeBarDelegate | undefined; private _showSessionActions = false; + private _metadataInInput = false; private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -153,9 +158,11 @@ export class ChatCompositeBar extends Disposable { @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @ICommandService private readonly _commandService: ICommandService, @ISessionsService sessionsService: ISessionsService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); + this._showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, configurationService); this._container = $('.chat-composite-bar.session-chat-tabs-bar'); // Tabs row — only shown when the group has multiple chats or is split out. @@ -285,6 +292,7 @@ export class ChatCompositeBar extends Disposable { this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats || isQuickChat); this._newChatAction.enabled = supportsMultipleChats && !isQuickChat && !delegate.session.isArchived.read(reader); this._showSessionActions = delegate.showSessionActions.read(reader); + this._metadataInInput = this._showMetadataInChatInput.read(reader); this._sessionActionsContainer.classList.toggle('hidden', !this._showSessionActions); this._updateMetaRowVisibility(); @@ -293,7 +301,7 @@ export class ChatCompositeBar extends Disposable { } private _updateMetaRowVisibility(): void { - this._metaRow.style.display = this._showSessionActions && !this._metaToolbar.isEmpty() ? '' : 'none'; + this._metaRow.style.display = this._showSessionActions && !this._metadataInInput && !this._metaToolbar.isEmpty() ? '' : 'none'; } setAriaLabel(label: string): void { diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index eb10414482596..cf3383c081eb0 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -90,6 +90,7 @@ export class ChatGroupsView extends Themable { private _mainChatResource: IObservable | undefined; private _sessionActive = true; private _sessionVisible = true; + private readonly _singleGroupTabsReplaceHeader = observableValue(this, false); /** While restoring a persisted layout: routes (late-loading) chats back to their saved groups. */ private _restoreAssignment: Map | undefined; @@ -113,6 +114,10 @@ export class ChatGroupsView extends Themable { super(themeService); } + setSingleGroupTabsReplaceHeader(enabled: boolean): void { + this._singleGroupTabsReplaceHeader.set(enabled, undefined); + } + /** Sets (or clears) the session whose chats this view partitions into groups. */ setSession(session: IActiveSession | undefined, options: IChatViewOptions): void { this._options = options; @@ -280,7 +285,7 @@ export class ChatGroupsView extends Themable { } return session.shouldShowChatTabs.read(reader); }); - const showSessionActions = derived(reader => this._groupCount.read(reader) === 1 && tabsVisible.read(reader)); + const showSessionActions = derived(reader => this._singleGroupTabsReplaceHeader.read(reader) && this._groupCount.read(reader) === 1 && tabsVisible.read(reader)); const view = store.add(this._instantiationService.createInstance(ChatGroupView)); const entry: IGroupEntry = { id, view, resourceIds, activeResourceId, chats, tabsVisible }; diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 80f240ee0a10f..f82c736eb8f98 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -186,11 +186,11 @@ overflow: hidden; } -.chat-groups-view.single-group .chat-composite-bar-tabs-row { +.session-view.tabs-replace-header .chat-groups-view.single-group .chat-composite-bar-tabs-row { border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); } -:is(.hc-black, .hc-light) .chat-groups-view.single-group .chat-composite-bar-tabs-row { +:is(.hc-black, .hc-light) .session-view.tabs-replace-header .chat-groups-view.single-group .chat-composite-bar-tabs-row { border-bottom-color: var(--vscode-contrastBorder); } diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index ff65256a864c9..39a4f94ca5698 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -18,12 +18,15 @@ import { AbstractChatView, IChatViewOptions } from './chatView.js'; import { ChatGroupsView } from './chatGroupsView.js'; import { SessionHeader, SessionViewFloatingToolbar } from './sessionHeader.js'; import { ISessionContext, SessionContext } from '../../services/sessions/browser/sessionContext.js'; -import { autorun, observableValue } from '../../../base/common/observable.js'; +import { autorun, IObservable, observableValue } from '../../../base/common/observable.js'; import { SessionIsMaximizedContext } from '../../common/contextkeys.js'; import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; import { setActiveSessionContextKeys } from '../../services/sessions/common/sessionContextKeys.js'; import { applySessionViewThemeColors } from './sessionBarStyles.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; /** * Options passed to {@link SessionView.openSession}. Extends the chat view @@ -83,15 +86,18 @@ export class SessionView extends Disposable implements ISerializableView { private _isLeafVisible = true; private readonly _sessionObs = observableValue(this, undefined); + private readonly _showMetadataInChatInput: IObservable; constructor( @IChatViewFactory private readonly _chatViewFactory: IChatViewFactory, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IThemeService private readonly themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); + this._showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, configurationService); // Scoped context key service so toolbars hosted within can react to // session-specific context keys (e.g. sessionIsCreated, sessionIsSticky). const scopedContextKeyService = this._scopedContextKeyService = this._register(contextKeyService.createScoped(this.element)); @@ -151,10 +157,13 @@ export class SessionView extends Disposable implements ISerializableView { this._register(autorun(reader => { const session = this._sessionObs.read(reader); - const tabsReplaceHeader = this._groupsView.groupCount.read(reader) === 1 + const tabsReplaceHeader = this._showMetadataInChatInput.read(reader) + && this._groupsView.groupCount.read(reader) === 1 && (session?.isCreated.read(reader) ?? false) && (session?.shouldShowChatTabs.read(reader) ?? false); this._header.setVisible(!tabsReplaceHeader); + this._groupsView.setSingleGroupTabsReplaceHeader(tabsReplaceHeader); + this.element.classList.toggle('tabs-replace-header', tabsReplaceHeader); })); } diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index d0e63cafca2e7..018465ca755e6 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -14,11 +14,16 @@ import { URI } from '../../../base/common/uri.js'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.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 { MenuRegistry } from '../../../platform/actions/common/actions.js'; import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; import { ChatCompositeBar, IChatCompositeBarDelegate } from '../../browser/parts/chatCompositeBar.js'; import { getSessionChatDragData, isSessionChatDrag } from '../../browser/dnd.js'; import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; +import { Menus } from '../../browser/menus.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; @@ -83,16 +88,19 @@ interface IChatCompositeBarHarness { readonly tabs: readonly HTMLElement[]; } -function createHarness(disposables: Pick, isQuickChat = false): IChatCompositeBarHarness { +function createHarness(disposables: Pick, options?: { readonly isQuickChat?: boolean; readonly showMetadataInInput?: boolean }): IChatCompositeBarHarness { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const commandService = new TestCommandService(); const sessionsService = new TestSessionsService(); const mainChat = createChat('main', 'Main Chat'); const secondaryChat = createChat('secondary', 'Secondary Chat'); - const session = createSession([mainChat, secondaryChat], mainChat, isQuickChat); + const session = createSession([mainChat, secondaryChat], mainChat, options?.isQuickChat); instantiationService.stub(ICommandService, commandService); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING]: options?.showMetadataInInput ?? false, + })); instantiationService.stub(ISessionsService, sessionsService); instantiationService.stub(ISessionsManagementService, new class extends mock() { override readonly onDidChangeSessions = Event.None; @@ -145,11 +153,21 @@ suite('Sessions - ChatCompositeBar', () => { }); test('hides New Chat for workspace-less sessions', () => { - const { bar } = createHarness(disposables, true); + const { bar } = createHarness(disposables, { isQuickChat: true }); assert.strictEqual(bar.element.querySelector('.chat-composite-bar-new-chat')?.classList.contains('hidden'), true); }); + test('hides header metadata pills when they are configured in the chat input', () => { + disposables.add(MenuRegistry.appendMenuItem(Menus.SessionHeaderMeta, { + command: { id: 'test.sessionMetadata', title: 'Changes' }, + group: 'navigation', + })); + const { bar } = createHarness(disposables, { showMetadataInInput: true }); + + assert.strictEqual(bar.element.querySelector('.chat-composite-bar-meta-row')?.style.display, 'none'); + }); + test('middle-click closes the targeted inactive non-main chat', () => { const { store, commandService, sessionsService, bar, session, tabs } = createHarness(disposables); let bubbled = 0; diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index e73a2960f8826..2785c881c9ffa 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -152,7 +152,7 @@ interface IChatGroupsHarness { readonly view: ChatGroupsView; } -function createHarness(disposables: Pick): IChatGroupsHarness { +function createHarness(disposables: Pick, tabsReplaceHeader = true): IChatGroupsHarness { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const sessionsService = new TestSessionsService(); @@ -169,6 +169,7 @@ function createHarness(disposables: Pick): IChatGroupsHa }()); const view = store.add(instantiationService.createInstance(ChatGroupsView)); + view.setSingleGroupTabsReplaceHeader(tabsReplaceHeader); mainWindow.document.body.appendChild(view.element); store.add(toDisposable(() => view.element.remove())); return { instantiationService, sessionsService, chatViewFactory, view }; @@ -455,4 +456,13 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('hides session actions when tabs do not replace the header', () => { + const { view } = createHarness(disposables, false); + const main = createChat('main'); + const secondary = createChat('secondary'); + view.setSession(new TestActiveSession([main, secondary]), options); + + assert.strictEqual(view.element.querySelector('.session-chat-tabs-actions')?.classList.contains('hidden'), true); + }); + });