diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index 3adf8957acda79..61b0a1756ac5d5 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 0288ef46749b5e..16b002502f7d3e 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/.cachesalt b/build/.cachesalt index f5c2b5725ddbce..bd7579fd483e09 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 2f1423a7d1f822..a7cc0ab6f3d20b 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 d40b360b8f33dc..fd9d88e8907546 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 b4cadd1ec84189..3317e43816e990 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 dbcd21676665d5..ea09134e1a863d 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 cc61a7a015a63a..4f935de7336480 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 eed6ebdd19925c..528a580afe3e4b 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/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 7859d45cef764e..593a6366dbd202 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/build/npm/dirs.ts b/build/npm/dirs.ts index 289a469754a5b1..7bc33635f8681e 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 432f521df94c08..b9221655462a09 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/extensions/copilot/package.json b/extensions/copilot/package.json index 7c31bb21cc389c..576eb3fcab9828 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" ], @@ -4341,6 +4342,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_tokens", + "markdownDescription": "%github.copilot.config.chatCompletionsTokenParameter%", + "tags": [ + "advanced", + "onExp" + ] + }, "github.copilot.chat.inlineEdits.xtabProvider.modelConfiguration": { "type": [ "object", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index a26edcc115607e..3bba9870f511eb 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 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", "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/extension/extension/vscode/contributions.ts b/extensions/copilot/src/extension/extension/vscode/contributions.ts index 8bc63ff84e0174..4a021168ce6355 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 00000000000000..87dcfc7e069699 --- /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/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 80a2c7825ffa37..07fa40f65641c5 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'; @@ -751,6 +751,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_tokens', vEnum('max_completion_tokens', 'max_tokens')); /** * Internal: override the routing tier sent to `POST /auto`, ignoring both the diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 75f3bd7f5d28c8..64111f1a16bf26 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -344,7 +344,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'; } @@ -361,6 +361,18 @@ export class ChatEndpoint implements IChatEndpoint { body.stream = false; } + 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 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 44c6f8ce013197..60da785d934388 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -17,7 +17,7 @@ import { IDomainService } from '../../../endpoint/common/domainService'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../endpoint/common/endpointProvider'; import { IEnvService } from '../../../env/common/envService'; 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,6 +241,145 @@ describe('CopilotChatEndpoint - Reasoning Properties', () => { }); }); +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, supportedEndpoints = [ModelSupportedEndpoint.ChatCompletions]) => { + const baseMetadata = createNonAnthropicModelMetadata(family); + const modelMetadata: IChatModelInformation = { + ...baseMetadata, + id: modelId, + name: displayName, + supported_endpoints: supportedEndpoints, + custom_model: customModel ? { + key_name: modelId, + owner_name: 'organization' + } : 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_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 } + ])('preserves max_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, + 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: 256000, + max_completion_tokens: undefined + }); + }); + + 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_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([{ + 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: undefined, + max_completion_tokens: 4096 + }); + }); + + 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 + }; + + 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', () => { let mockServices: ReturnType; diff --git a/package-lock.json b/package-lock.json index 53691fbc1a7043..2c53ca845ce75f 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 317255c0d13c52..3c518289b1eb2b 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 f1a5c864cbd355..0d791e25e1bdf4 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 1011b29dcc829e..d53cd5c6bad459 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 becf0ef8430050..c98679ac2f54f9 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 7da53e358296b0..6ebeca9775a874 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", diff --git a/src/vs/base/browser/contextmenu.ts b/src/vs/base/browser/contextmenu.ts index f8d7707b8ae4e9..895bf2c923298a 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 9d50417de51894..e8e645456467d9 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/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 22430f76741a93..63c37073b00aac 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/base/test/browser/ui/contextview/contextview.test.ts b/src/vs/base/test/browser/ui/contextview/contextview.test.ts index 2d8fd72ab0cd54..2b5fa82f0049c1 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/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 0a17dd5fb810d1..7be2b56210e9f5 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 8e93b4f8962b8a..002a646054cbfc 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/contrib/contextmenu/browser/contextmenu.ts b/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts index 6bcb8223428ab1..830f8668f9473a 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 ed9ff06a2e2987..fcfbab8c0241d8 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/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index f6859bb4eabddf..1dc0a859a3057b 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/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 10ec2d438d5495..834ed14151cd8b 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/common/agentSdkSetup.ts b/src/vs/platform/agentHost/common/agentSdkSetup.ts new file mode 100644 index 00000000000000..d37ecf55d4a5ab --- /dev/null +++ b/src/vs/platform/agentHost/common/agentSdkSetup.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +/** + * 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}`; +} + +/** + * 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/common/meta/agentSnapshotAttachmentMeta.ts b/src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts new file mode 100644 index 00000000000000..7d8e62d1782eeb --- /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/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 4d05f64cd3e665..3cf0ec824c6c95 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/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index a0fc9e7fb109c7..e29b9d17b1478b 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/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 891246628310fb..1772836f753aa2 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 7563e195929d97..9fab75587c62b6 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/agentSdkDownloadTelemetry.ts b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts new file mode 100644 index 00000000000000..8993588eefe71a --- /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 0e4786cb61acd8..1d59ac95cb129c 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 00000000000000..3e04c540b15a17 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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'; + +/** 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, 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 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} + * 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 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._lastRequests.set(key, request.request); + this._configurationService.updateRootConfig({ [key]: undefined }); + return true; + } + + /** + * 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(); + } + 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 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/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 02cc9da6f8d40b..865413420411f5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,8 +37,9 @@ 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'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; import { IProductService } from '../../product/common/productService.js'; @@ -124,6 +125,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 +844,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 +865,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 +1659,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 +1677,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 +1713,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 +1746,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 +2161,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 +2207,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 +2344,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 +2394,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 +2418,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; } @@ -3873,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; @@ -4470,10 +4541,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; } @@ -4483,7 +4555,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; @@ -4491,6 +4563,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); } @@ -4514,32 +4610,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)}`); @@ -4547,6 +4651,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. @@ -4589,6 +4704,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); @@ -4599,7 +4715,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/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index 15f3a9a19aadbc..f2dc872ed5e698 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/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5b6fa6bb4d09b3..2f9928c9d6e50a 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/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 026930e22311c2..b5a70e97d81a61 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 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 * 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 46a2df473581e7..d53bc976c3879a 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/claudePromptResolver.ts b/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts index b06b6b298213eb..c19bcbb9d6e1de 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/claude/claudeTransportMode.ts b/src/vs/platform/agentHost/node/claude/claudeTransportMode.ts index 0e165ddac3f3d9..e594e318201210 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 79f419b054ffe4..4f18e4361684ea 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 link. */ +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 b04d0633b4772e..00000000000000 --- 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/node/codex/codexPromptResolver.ts b/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts index 1e8b5aa8b59026..0a2c66999382da 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 bf80387d411359..19fcb12d5f3730 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; @@ -1430,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, @@ -1456,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, @@ -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 7709e6d1bbdd55..b0b49eeb4bd0de 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/common/agentSdkSetup.test.ts b/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts new file mode 100644 index 00000000000000..581fd741f65d1c --- /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/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index e362c15476d86a..265519a4400e67 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 () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index bdace033db24e5..1a1f5c8bd568db 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 d01bc18bbc823e..ec4e1f44acf80e 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 149dba8e090e0a..d49b0e0c00d9cd 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; 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 00000000000000..22d7529cfd6ea9 --- /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 9df7962594660d..b97ae40414277e 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/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9f17a9b43c8a94..c5d1e6d771a6ed 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'; @@ -94,11 +95,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 +1060,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 +1118,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); @@ -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 () => { @@ -2934,11 +2939,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 +2953,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 +3030,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 +3068,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 +3126,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 +3150,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 +3165,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 +3180,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 +3202,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 +3228,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 +3324,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 +3436,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 +3447,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 +3467,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 +3510,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 +3770,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 +3867,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 +3877,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 +4013,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 +4063,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 +4084,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 +4222,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 +4273,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 +4311,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 +4319,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 +4377,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 +4401,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 +4655,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 +4869,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 +4915,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 +4938,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 +4959,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 +4977,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 +5003,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 +5038,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 +5085,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 +5246,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 +5256,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 +5275,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/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index f2cc47addcc4c4..c90696623859ee 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 00000000000000..b6a63a06347a56 --- /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/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 69166cf147cb46..1ba7a034e411ff 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 298c1648327a4a..f9e27fd7b8fbcb 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, 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'; 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,263 @@ 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 } }); + } + + /** 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++) { + 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://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, + }); + }); + + 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: [], + }); + }); + + 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', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -6202,56 +6508,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 +6592,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 +6721,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 +6837,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 +8441,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 +8450,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, sdkDownloader], [IAgentPluginManager, pluginManager], [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], @@ -8176,7 +8496,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 922ca21121bc44..6f63f4bebd1ae0 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 537fd852b08ec2..80bf009c102269 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 893542b48e4fcb..ad65982286323a 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 d970e1e69e25cd..00000000000000 --- 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 52ee076207d0d8..c85f706402d68f 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, 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'; @@ -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,201 @@ 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 } }); + } + + /** 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++) { + 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, + }); + }); + + 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/platform/agentHost/test/node/codex/codexPromptResolver.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts index 67a30e4a1b6062..0b00c09151badf 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/codex/codexSessionConfigKeys.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts index c4e6a27014573e..ab59ebd65eadb6 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 88b2996b9ad51a..8e6ddfc1a4b3e2 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/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b0b984a5d4d6e4..9c3945f0d7db13 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'); @@ -6345,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'); @@ -6397,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 }), @@ -6453,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', { @@ -6494,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, }]); }); 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 074390d8670810..601dec1dd2383d 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/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index da0850f9e6b4e9..7873d7e9cc01e2 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); 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 00000000000000..ecb6b3ff81c244 --- /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/platform/chat/common/chatSettings.ts b/src/vs/platform/chat/common/chatSettings.ts index 397749566d3eaf..6ed932c63b0313 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/platform/contextview/browser/contextMenuHandler.ts b/src/vs/platform/contextview/browser/contextMenuHandler.ts index d1b62abba07b9a..e4be42a0667d29 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 c6cf7a57d4cb1b..1264dc4ae07ca4 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 1ca549dc76c689..02d22e758405c7 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/sessions/browser/media/phoneLayout.css b/src/vs/sessions/browser/media/phoneLayout.css index b3ca9af8a561f3..add028b8782d50 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 dd8dc3e3eea148..7af1ca383140fa 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/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 13c6658ff72167..5784b43920bacc 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -39,6 +39,14 @@ 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'; +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; @@ -57,7 +65,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 +81,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 +111,13 @@ 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 _showMetadataInChatInput: IObservable; private readonly _tabs: IChatTab[] = []; private readonly _tabDisposables = this._register(new DisposableStore()); @@ -107,8 +125,8 @@ 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 _metadataInInput = false; private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -139,9 +157,12 @@ export class ChatCompositeBar extends Disposable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @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. @@ -159,6 +180,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 +228,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 +268,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 +287,23 @@ 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._metadataInInput = this._showMetadataInChatInput.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._metadataInInput && !this._metaToolbar.isEmpty() ? '' : 'none'; + } + setAriaLabel(label: string): void { this._tabsContainer.setAttribute('aria-label', label); } @@ -301,7 +348,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 500573c6449638..d7307f3edb6a27 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 5ef6dcd2a0c7f5..cf3383c081eb02 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,6 +285,7 @@ export class ChatGroupsView extends Themable { } return session.shouldShowChatTabs.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 }; @@ -295,6 +301,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 93943c6187a032..f82c736eb8f98e 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; } +.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) .session-view.tabs-replace-header .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 73b02e784f1520..00000000000000 --- 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 3baf192e26a741..fcde5fa5b2272e 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 38a35ed78a374c..39a4f94ca56980 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)); @@ -148,6 +154,17 @@ 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._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); + })); } openSession(session: IActiveSession | undefined, options: ISessionViewOptions): void { diff --git a/src/vs/sessions/browser/sessionConversationGroups.ts b/src/vs/sessions/browser/sessionConversationGroups.ts index b17af138724b97..4c16c3881a715d 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 a23cc17e24c506..d32c7e77415fa3 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/browser/sessionsAuthGate.ts b/src/vs/sessions/browser/sessionsAuthGate.ts index 1745a46bb8b75c..ca553b8d88f38e 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/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b7cefca90e5fe6..da01e482d48a13 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/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 157073097f5acb..eb15ba98562ae5 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/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index 4bdc5cd1d2a72f..d00c461da5819a 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 82d5a1d70897e8..68c5a9996a09e7 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 216af231945970..6d6c973ae4a841 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 a4f14b2c7bc21f..833b374f7d3e50 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/chat/browser/externalSessionBanner.ts b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts index ad38790bb8097f..8280e1178a0a5d 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/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index 06c198ef041250..c0815437da19a2 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 99023ab0df3cdf..76f8116e367b12 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/externalSessionBanner.test.ts b/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts index c5841d767e591a..10a45a6ea21c7b 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/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts index 633c3026b64bb3..1355c07f504b71 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/editor/browser/diffEditor.sessions.contribution.ts b/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts index b9d277d7622ac2..79c7e05be4ce2c 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 00000000000000..024d24cd0b59b9 --- /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 00000000000000..1f1c1120f7cde5 --- /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 6019dcab644e83..526adffd295442 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 00000000000000..86db04f84c08c3 --- /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, + }); + }); +}); 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 7466e89dfef458..00000000000000 --- 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/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 2dd2d6a76cd797..39829d7e81b78a 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/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts index 6d7906174b7143..65448ea723cc19 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/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index 0d0fd622c88933..6fb98067d5ef9d 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/agentHost/test/browser/agentHostDiscoveredConfigNotification.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostDiscoveredConfigNotification.test.ts deleted file mode 100644 index 52dede3c4f86d4..00000000000000 --- 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/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index 1111985083d5aa..17ef3412c0078f 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/sessions/contrib/sessions/browser/media/automationsCards.css b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css index 048d9d368debd9..576d8ad980d265 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/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index a276f88703beab..1a42235b226dda 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/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 557d711fc050a8..0b37589a225e27 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 6a0bd7123b3161..38a00abdd3d8a4 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 7f02a91e7567d6..45b14b6cb78be3 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 af7da5966f8b9d..94dcfe9da6c035 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/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index abed31eecf62f4..fa408bc24e7335 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 = { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 115b3cea2b1558..18bcec1f5727f9 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/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index e3d5e8e40a77e6..b22a8d4c953c17 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 00000000000000..5ded37d19d0d98 --- /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/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 5dd49f4e5b3d76..28861b2245a723 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, diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 870429bba093c3..76285864761a23 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/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 3ee3a41457de05..d7d5a085f33222 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/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index eaffe9687f3481..018465ca755e6a 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'; @@ -53,7 +58,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 +74,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,16 +88,19 @@ interface IChatCompositeBarHarness { readonly tabs: readonly HTMLElement[]; } -function createHarness(disposables: Pick): 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); + 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; @@ -109,6 +118,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 +142,32 @@ 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, { 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 1638a2f14a5f0f..2785c881c9ffa3 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 }; @@ -402,20 +403,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 +434,35 @@ 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], + }); + }); + + 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); + }); + }); diff --git a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts index 95b3fb8c724c30..a194d0f41a70b2 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 059d8c2bfe3f97..601b50d6adae55 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/sessions/test/browser/sessionsAuthGate.test.ts b/src/vs/sessions/test/browser/sessionsAuthGate.test.ts index c53568bd9c0382..42e554457d2726 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/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 1da1f2fb5bcc7d..1d86c3ad5d2550 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 082caa715ca573..a760121da4bf50 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/chat/browser/actions/chatModelFeedbackSurveyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatModelFeedbackSurveyActions.ts new file mode 100644 index 00000000000000..1263c832e4c389 --- /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 215aabfbeed2c3..f1b9f230a3997c 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/agentSessions/agentHost/agentHost.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHost.contribution.ts index a7816e4d81e084..50c6520f3e078a 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/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 24dd2294618b4a..ed9d0a3fab8806 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/agentSessions/agentHost/agentHostSdkSetupNotification.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts new file mode 100644 index 00000000000000..e48a275a2c1817 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.ts @@ -0,0 +1,356 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +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 + +/** 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 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): 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 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 (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 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)); +} + +/** + * 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.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_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'; + +/** + * 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_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)); + +// #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/externalSessionsFilterMenu.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts index 603d6cec71614e..1f4fbdf88233d7 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/agentSessions/sessionTypeAvailability.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/sessionTypeAvailability.ts index 4b2aa3d18886d0..b06795a42a16d2 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/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index caf77a01582b57..75d1998716f01d 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'; @@ -397,13 +400,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 +2442,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) => { @@ -3005,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); @@ -3047,6 +3060,7 @@ registerWorkbenchContribution2(TranscriptContextAttachmentWidgetContribution.ID, registerChatActions(); registerChatAccessibilityActions(); registerChatCopyActions(); +registerChatModelFeedbackSurveyActions(); registerChatOpenAgentDebugPanelAction(); registerChatCodeBlockActions(); registerChatCodeCompareBlockActions(); @@ -3088,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 00000000000000..f0bbf1b0b0993e --- /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 00000000000000..b7d800da4dd5d1 --- /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 00000000000000..4dc275f7f3f418 --- /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 00000000000000..11ab45dd984379 --- /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 fab5aca8fb37b4..778643d1183e00 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 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() 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 d97be275daa357..71f0e87cb0e1bb 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/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 4f16c1ee51067c..f9c8c461361d8c 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); 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 ec7bf02168ff45..53f072d7d11920 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 b60a4f3b64f395..ad34f213d3429f 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/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 3425dbe134d87d..0a4e00323d0e23 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -18,6 +18,8 @@ export namespace ChatContextKeys { export const responseSupportsIssueReporting = new RawContextKey('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 00000000000000..acf05a5f6513e5 --- /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 00000000000000..2edeb8ddef31ce --- /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 a6ed9382dc127f..7e094509e441b9 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/agentSessions/agentHostSdkSetupNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts new file mode 100644 index 00000000000000..0ccad83e4ff2c1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSdkSetupNotification.test.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * 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_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'; + +/** 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'), + }, { + // 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_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, 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) => { + 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({}), + docs: noAccount({ setupDocsUrl: 'https://example.test/claude' }), + signIn: noAccount({ signInProviderName: 'ChatGPT' }), + both: noAccount({ setupDocsUrl: 'https://example.test/claude', signInProviderName: 'ChatGPT' }), + }, { + 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'); + + 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/externalSessionsFilterMenu.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts index ea545123c3450a..80b5849b556a0f 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 }, ], }); }); 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 48fcbb51ff3a31..5faad77b5a52c7 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/feedbackSurvey/chatModelFeedbackSurveyActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyActions.test.ts new file mode 100644 index 00000000000000..5ffbf289b347d4 --- /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 00000000000000..9764b785d1763c --- /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 00000000000000..3030c14d9a22ba --- /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 00000000000000..2156c6fe4f1009 --- /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 00000000000000..0b9609201fef42 --- /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 7ac24846a99e96..0537d740c34f9b 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 079d86f1ec227c..12db45d6c45f37 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/browser/widget/input/sessionTargetPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts index 63bffc65425574..4558d92cc9d27b 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/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 00000000000000..d1aa7fc0655c1a --- /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' + }); + }); +}); 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 00000000000000..fed3f0ddc4ecc9 --- /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/contrib/debug/node/terminals.ts b/src/vs/workbench/contrib/debug/node/terminals.ts index b3b5a84c411298..ed0fea2112f0c8 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}}='${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 ea756282142810..fe53e62f7ceb3f 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,33 @@ suite('Debug - prepareCommand', () => { prepareCommand('powershell', ['arg1', '>', '> hello.txt', '<', ' '> hello.txt' < ' { + const leftSingleQuotationMark = '\u2018'; + const rightSingleQuotationMark = '\u2019'; + + 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(), + 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(), + ], + [ + '${env:SIMPLE}=\'hello\';', + '${env:SPACES}=\'hello world\';', + '${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\\\';', + ]); + }); }); 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 0d25f80008da80..36d3cec8cfa10d 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')); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css b/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css index 6d9d3804e9eaf0..dafd2d8db21d5b 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 d0c11dceb720c9..61a5f5406dc7bf 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 8dbe2f2c4aba16..ee88b721c3e12d 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 c756ba0ea19262..632037c40fb09b 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'; diff --git a/src/vs/workbench/contrib/performance/electron-browser/startupTimings.ts b/src/vs/workbench/contrib/performance/electron-browser/startupTimings.ts index fe0d86ad970ba4..a5806826a973b0 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); + } } } 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 00000000000000..42a1709fc1a449 --- /dev/null +++ b/src/vs/workbench/services/agentHost/browser/agentSdkSetupService.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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'; +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' + | 'reloadClicked'; + +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, reloadClicked, 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; + + /** + * 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; + + /** 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 }); + } + + 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. + 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); + 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) { + // 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 390ce39654a526..fbb2f8fd8a2019 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 aec3277eaa3c5e..5731dd4d83583d 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, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index 798a009ff7dfe5..c67ff4ba93413f 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; } 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 93cc4d932a3c5d..5f11c34aaa24a4 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 1748c2b79272f4..d75e7f56d75d63 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, { 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 00000000000000..6e59ba344b7b4e --- /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 + }); + + }); +}); diff --git a/test/mcp/package.json b/test/mcp/package.json index bbddd2ed4fcf0a..9938aa648a077d 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 ef2345e707f4c9..97a070b98d321d 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 8b06471dda9334..e32553eecce268 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 5157bf19ddd882..aeecaf93a3d80f 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 d81bc9570c4577..dfc523ab2547b2 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 b9dfb2951f3b53..268f209d515e24 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 0af80f18650121..67436bbfd50a53 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 777d7e3dfcb719..7f46d8107e62ad 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 e379a7d90e4ee2..40a54a2f8f2f41 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 e5f595fb47fbd7..0cd707451e649b 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 28908119e0d687..8fec6893862e1b 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 bff17b43e7042b..0b468655eb661f 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 68ce82ca6eba54..a157033ac0d9b3 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 4ff55b05270f88..88e82d184cabf2 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 c47f8bb00cbbd6..5852a5438f808b 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 b8f9c500c01ee5..1b71ade39ba949 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 55feec02b69029..ee6516cb5e2379 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 040ce94b68b5f0..810dbba20af1ef 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 46f91fe8fbf7a8..89d3cf8a5b39d9 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 2944d2c9faffcf..e5c06f907924df 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 270d596fc18551..82a871ae841ba8 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 eaab27effdfc38..fd72d2a7ad251a 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 132cb2841c196e..863027529dc749 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 00000000000000..f5c2caac622283 --- /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 ea8193baa2f33d..e6c9a4bc27cb22 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 00000000000000..e7d563c46ad50e --- /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 00000000000000..afafe0902c1003 --- /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 00000000000000..d6f2c905bb9ab4 --- /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 9bfb2e8241c164..dfbe0e00d72de5 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 aa124aa0e1f028..d2a3f3c029d683 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 00000000000000..f8a985856c7ac9 --- /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 697fb9b54c4c51..aa45a1b72173bc 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 00000000000000..5341c14f225e73 --- /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" + ] +}