From e0c5a93c45a129edb39299643f62d68cddab1824 Mon Sep 17 00:00:00 2001 From: yogeshwaran-c Date: Mon, 6 Apr 2026 19:35:46 +0530 Subject: [PATCH 001/696] Add copy button to exception peek view in debugger Adds a copy icon button next to the close button in the exception widget, allowing users to copy the exception details (title, description, and stack trace) to clipboard with a single click. Fixes #225086 --- .../contrib/debug/browser/exceptionWidget.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts b/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts index 25aa89b6c6faa..0c0d408619575 100644 --- a/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts +++ b/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts @@ -21,6 +21,8 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Action } from '../../../../base/common/actions.js'; import { widgetClose } from '../../../../platform/theme/common/iconRegistry.js'; import { Range } from '../../../../editor/common/core/range.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; const $ = dom.$; @@ -39,7 +41,8 @@ export class ExceptionWidget extends ZoneWidget { private debugSession: IDebugSession | undefined, private readonly shouldScroll: () => boolean, @IThemeService themeService: IThemeService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IClipboardService private readonly clipboardService: IClipboardService ) { super(editor, { showFrame: true, showArrow: true, isAccessible: true, frameWidth: 1, className: 'exception-widget-container' }); @@ -84,6 +87,10 @@ export class ExceptionWidget extends ZoneWidget { let ariaLabel = label.textContent; const actionBar = this._disposables.add(new ActionBar(actions)); + actionBar.push(new Action('editor.copyExceptionInfo', nls.localize('copy', "Copy"), ThemeIcon.asClassName(Codicon.copy), true, async () => { + const clipboardText = this.buildExceptionText(); + await this.clipboardService.writeText(clipboardText); + }), { label: false, icon: true }); actionBar.push(new Action('editor.closeExceptionWidget', nls.localize('close', "Close"), ThemeIcon.asClassName(widgetClose), true, async () => { const contribution = this.editor.getContribution(EDITOR_CONTRIBUTION_ID); contribution?.closeExceptionWidget(); @@ -132,6 +139,22 @@ export class ExceptionWidget extends ZoneWidget { } } + private buildExceptionText(): string { + const parts: string[] = []; + if (this.exceptionInfo.id) { + parts.push(nls.localize('exceptionThrownWithId', 'Exception has occurred: {0}', this.exceptionInfo.id)); + } else { + parts.push(nls.localize('exceptionThrown', 'Exception has occurred.')); + } + if (this.exceptionInfo.description) { + parts.push(this.exceptionInfo.description); + } + if (this.exceptionInfo.details?.stackTrace) { + parts.push(this.exceptionInfo.details.stackTrace); + } + return parts.join('\n'); + } + focus(): void { // Focus into the container for accessibility purposes so the exception and stack trace gets read this.container?.focus(); From 9ebf9740e47607a3176c1b079c36bd1d20a1e51d Mon Sep 17 00:00:00 2001 From: LuDaShi Date: Mon, 22 Jun 2026 13:14:54 +0800 Subject: [PATCH 002/696] =?UTF-8?q?cnb=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cnb.yml | 64 ++++++++++++++++++++++++++++++++++ .cnb/settings.yml | 10 ++++++ .github/workflows/sync-cnb.yml | 21 +++++++++++ .ide/Dockerfile | 37 ++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 .cnb.yml create mode 100644 .cnb/settings.yml create mode 100644 .github/workflows/sync-cnb.yml create mode 100644 .ide/Dockerfile diff --git a/.cnb.yml b/.cnb.yml new file mode 100644 index 0000000000000..cf291737cb69a --- /dev/null +++ b/.cnb.yml @@ -0,0 +1,64 @@ +# .cnb.yml +.npc: &npc + - services: + - docker + stages: + - name: run with npc + image: cnbcool/default-npc-agent:latest + +好大个: + issue.comment@npc: *npc + pull_request.comment@npc: *npc + +$: + vscode: + - runner: + cpus: 32 + docker: + build: .ide/Dockerfile + env: + # 声明首次打开终端时自动执行的命令,支持多行文本 + CNB_WELCOME_CMD: | + echo "Hello World!" + echo done + services: + - vscode + - docker + # 开发环境启动后会执行的任务 + stages: + - name: ls + script: ls -al + tag_push: + - stages: + - name: 生成 Code Wiki + timeout: 4h + image: cnbcool/codewiki:latest + settings: + git_doc_dir: /${CNB_BUILD_WORKSPACE}/${CNB_REPO_SLUG}/codewiki + +# .cnb.yml +"**": # 触发的分支名,默认所有分支,可按需修改 + push: # push 触发,可按需修改为 pull_request 等 + - stages: + # 代码分析 + - name: TCA + timeout: 2h + image: cnbcool/tca:latest + settings: + from_file: # 可选,可以传递一个文件(比如 changed.txt),内容为需要分析的文件列表,一行一个文件(采用基于工作空间的相对路径) + ignore_paths: # 可选,指定一个或多个相对工作空间的文件屏蔽路径(黑名单),多个路径可写成数组格式,路径采用Unix通配符格式。 + white_paths: # 可选,指定一个或多个相对工作区的扫描路径(白名单),多个路径可写成数组格式,路径采用Unix通配符格式。 + block: # 可选,默认为true, 如果有代码问题或分析异常,会阻塞流水线。如果不希望阻塞流水线,可以设置为false。 + + - stages: + - name: build knowledge base + image: cnbcool/knowledge-base + settings: + include: '**/*.md,**/*.mdx,**/*.pdf,**/*.docx,**/*.txt' + issue_sync_enabled: true + issue_labels: + - bug + - feature + issue_state: "open" + issue_priority: "P0,P1" + diff --git a/.cnb/settings.yml b/.cnb/settings.yml new file mode 100644 index 0000000000000..46af23c5bb65e --- /dev/null +++ b/.cnb/settings.yml @@ -0,0 +1,10 @@ +npc: + roles: + - name: 好大个 + prompt: | + 你用"好大个"自称,叫用户"大人", + 你的口头禅是『此事必有蹊跷!』, + 结束对话前礼貌地回复一行:"此事背后一定有一个天大的秘密。" + 无论是日常对话还是讲解知识,你都会保持以上风格, + 使用中文的『~』代替所有英文的『~』, + 用卑微并专业的语气回答接下来的问题 \ No newline at end of file diff --git a/.github/workflows/sync-cnb.yml b/.github/workflows/sync-cnb.yml new file mode 100644 index 0000000000000..00f01b367f025 --- /dev/null +++ b/.github/workflows/sync-cnb.yml @@ -0,0 +1,21 @@ +# .github/workflows/sync-cnb.yml + +name: Sync to CNB +on: [push] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Sync to CNB Repository + uses: docker://tencentcom/git-sync + env: + PLUGIN_TARGET_URL: "https://cnb.cool/soucod-github/soucod/vscode.git" + PLUGIN_AUTH_TYPE: "https" + PLUGIN_USERNAME: "cnb" + PLUGIN_PASSWORD: ${{ secrets.GIT_CNB_TOKEN }} + PLUGIN_FORCE: "true" diff --git a/.ide/Dockerfile b/.ide/Dockerfile new file mode 100644 index 0000000000000..02235ce2a492e --- /dev/null +++ b/.ide/Dockerfile @@ -0,0 +1,37 @@ +# .ide/Dockerfile + +# 可将 node 替换为需要的基础镜像 +FROM docker.cnb.cool/avwq/cnb.cool/cnb-avwq-default-dev-env-centos-stream:stream10 + +# 安装 code-server 和 vscode 常用插件 +RUN curl -fsSL https://code-server.dev/install.sh | sh \ + && code-server --install-extension cnbcool.cnb-welcome \ + && code-server --install-extension redhat.vscode-yaml \ + && code-server --install-extension dbaeumer.vscode-eslint \ + && code-server --install-extension waderyan.gitblame \ + && code-server --install-extension mhutchie.git-graph \ + && code-server --install-extension donjayamanne.githistory \ + && code-server --install-extension tencent-cloud.coding-copilot \ + && echo done + +# 安装 ssh 服务,用于支持 VSCode 等客户端通过 Remote-SSH 访问开发环境(也可按需安装其他软件) +RUN dnf update -y && dnf install -y git wget unzip openssh-server epel-release curl vim unzip procps && dnf install -y atop btop htop iftop iotop cockpit +# 业务安装 +RUN dnf install -y nodejs npm python && dnf groupinstall "Development Tools" && dnf install libX11-devel.x86_64 libxkbfile-devel.x86_64 libsecret-devel krb5-devel && dnf install fakeroot rpm +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone +RUN npm i -g pnpm + + + +# 创建 `/ide_cnb` 目录,用于安装 IDE。系统通过此目录自动识别已安装的 IDE,安装路径必须为 `/ide_cnb`。 +RUN mkdir -p /ide_cnb +# WebStorm +RUN wget https://download.jetbrains.com/webstorm/WebStorm-2026.1.tar.gz \ + && tar -zxvf WebStorm-2026.1.tar.gz -C /ide_cnb \ + && rm WebStorm-2026.1.tar.gz + + + +# 指定字符集支持命令行输入中文(根据需要选择字符集) +ENV LANG C.UTF-8 +ENV LANGUAGE C.UTF-8 From 85ef65cb37d2714c7a56dfbb174609863ed7240a Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 22 Jun 2026 14:33:07 +0200 Subject: [PATCH 003/696] agentHost: resolve Codex SDK from node_modules in dev Codex previously required an env-var override or a `product.agentSdks.codex` entry to be reachable, so running from source (and dev smoke tests) skipped Codex entirely. Claude already had a dev `node_modules` bare-import path; this gives Codex the equivalent. - Both host entrypoints (`agentHostMain`, `agentHostServerMain`) now register the Codex provider in dev with the same `!isBuilt || isAvailable(...)` gate Claude uses. - `CodexAgent._resolveSdkRoot` discriminates dev vs built via `downloader.isAvailable(...)` (the same trick Claude uses to avoid injecting `INativeEnvironmentService`) and falls back to `resolveCodexDevSdkRoot()`, which resolves `@openai/codex` out of this repo's `node_modules` and derives the SDK root the native binary is spawned from. Built products are unaffected: the dev fallback is only reachable when `isAvailable` is false, which in a built install means the provider never registers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentHostMain.ts | 5 +- .../agentHost/node/agentHostServerMain.ts | 8 ++- .../agentHost/node/codex/codexAgent.ts | 63 +++++++++++++++++-- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 0dd3691419e77..4972280b689c7 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -193,14 +193,15 @@ async function startAgentHost(): Promise { // so the bare-import path in `ClaudeAgentSdkService._loadSdk` // always succeeds in dev; in built products the SDK ships via // `product.agentSdks.claude` and the downloader handles it. Codex - // has no equivalent dev path yet, so it still requires either the + // is likewise a devDependency, so `CodexAgent._resolveSdkRoot` + // resolves it from `node_modules` in dev; built products use the // env-var override or a `product.agentSdks.codex` entry. // If either gate fails, the provider is not registered and never appears // in the agent picker (matches the pre-CDN UX exactly). if (isAgentEnabled(process.env[AgentHostClaudeAgentEnabledEnvVar], true) && (!environmentService.isBuilt || agentSdkDownloader.isAvailable(ClaudeSdkPackage))) { agentService.registerProvider(instantiationService.createInstance(ClaudeAgent)); } - if (isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false) && agentSdkDownloader.isAvailable(CodexSdkPackage)) { + if (isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false) && (!environmentService.isBuilt || agentSdkDownloader.isAvailable(CodexSdkPackage))) { agentService.registerProvider(instantiationService.createInstance(CodexAgent)); } } catch (err) { diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 5c428d754b3b5..80e2e275b337d 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -294,14 +294,16 @@ async function main(): Promise { // so the bare-import path in `ClaudeAgentSdkService._loadSdk` // always succeeds in dev; in built/shipped server installs the // SDK comes from the CLI flag / env var dev override or a - // `product.agentSdks.claude` entry. Codex still requires the - // env-var override or product config. + // `product.agentSdks.claude` entry. Codex is likewise a + // devDependency, so `CodexAgent._resolveSdkRoot` resolves it from + // `node_modules` in dev; built/shipped installs use the env-var + // override or `product.agentSdks.codex`. if (isAgentEnabled(process.env[AgentHostClaudeAgentEnabledEnvVar], true) && (!environmentService.isBuilt || agentSdkDownloader.isAvailable(ClaudeSdkPackage))) { const claudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); agentService.registerProvider(claudeAgent); log('ClaudeAgent registered'); } - if (isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false) && agentSdkDownloader.isAvailable(CodexSdkPackage)) { + if (isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false) && (!environmentService.isBuilt || agentSdkDownloader.isAvailable(CodexSdkPackage))) { const codexAgent = disposables.add(instantiationService.createInstance(CodexAgent)); agentService.registerProvider(codexAgent); log('CodexAgent registered'); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 9b2f5ed3a15aa..7679d33d2138d 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -5,6 +5,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; +import { createRequire } from 'node:module'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; @@ -784,15 +785,45 @@ export class CodexAgent extends Disposable implements IAgent { return promise; } + /** + * Resolve the Codex SDK root — the directory whose + * `node_modules/@openai/codex-/…` holds the native binary. + * + * Mirrors the three-tier resolution in `ClaudeAgentSdkService._loadSdk`: + * 1. dev override / product download, via the downloader, when the SDK + * `isAvailable` (env override || `product.agentSdks.codex`); + * 2. dev fallback to this repo's `node_modules`, where `@openai/codex` + * and its per-host binary package are devDependencies — this is what + * lets running-from-source (and dev smoke tests) spawn Codex without + * an env-var override. + * + * `isAvailable` is already false in dev, so it discriminates the two + * without injecting `INativeEnvironmentService`. When neither path + * resolves we defer to the downloader so callers get its actionable + * "not configured" diagnostic. + */ + private async _resolveSdkRoot(): Promise { + if (this._agentSdkDownloader.isAvailable(CodexSdkPackage)) { + return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + } + const devRoot = resolveCodexDevSdkRoot(); + if (devRoot) { + this._logService.info(`[Codex] resolving SDK from repo node_modules (dev fallback): ${devRoot}`); + return devRoot; + } + return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + } + private async _startConnection(token: string): Promise { - // Resolve the Codex SDK root via the downloader: dev override → cache → - // download from `product.agentSdks.codex`. We spawn the native codex - // binary inside the platform package directly (the same shape the JS - // shim at `node_modules/@openai/codex/bin/codex.js` would resolve to) - // — going through the shim adds a launcher hop and forces an + // Resolve the Codex SDK root: dev override / product download via the + // downloader, or this repo's `node_modules` in a source checkout (see + // `_resolveSdkRoot`). We spawn the native codex binary inside the + // platform package directly (the same shape the JS shim at + // `node_modules/@openai/codex/bin/codex.js` would resolve to) — going + // through the shim adds a launcher hop and forces an // `ELECTRON_RUN_AS_NODE` round-trip when the agent host runs as an // Electron utility process. - const root = await this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + const root = await this._resolveSdkRoot(); const codexTarget = codexPackageSuffix(process.platform, process.arch); if (!codexTarget) { throw new Error(`Codex: unsupported platform ${process.platform}-${process.arch}`); @@ -2583,3 +2614,23 @@ export function codexBinaryTriple(sdkTarget: string): string | undefined { default: return undefined; } } + +/** + * Locate the SDK root for the dev (running-from-source) fallback by resolving + * `@openai/codex` — a devDependency in source checkouts — out of this repo's + * `node_modules`. Returns the directory that *contains* that `node_modules` + * (i.e. the value `_startConnection` joins `node_modules/@openai/codex-` + * onto), or undefined when the package can't be resolved (e.g. a built product + * where it isn't shipped). `@openai/codex` declares no `exports` map, so its + * `package.json` is resolvable. + */ +function resolveCodexDevSdkRoot(): string | undefined { + try { + const nodeRequire = createRequire(import.meta.url); + const pkgJson = nodeRequire.resolve('@openai/codex/package.json'); + // /node_modules/@openai/codex/package.json → + return dirname(dirname(dirname(dirname(pkgJson)))); + } catch { + return undefined; + } +} From 146ed775a9ec5f843b4c43db112a804400416a56 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 22 Jun 2026 15:18:33 +0200 Subject: [PATCH 004/696] agentHost: dynamic node:module import for Codex dev SDK resolution + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the failing `codexPackagePaths` unit test. The static `import { createRequire } from 'node:module'` poisoned the whole module in the electron-renderer unit-test loader (bare builtins like `fs`/`child_process` are externalized there, but a static `node:module` import is fetched as a URL and fails — "Failed to fetch dynamically imported module"). Resolve `createRequire` via a dynamic `await import('node:module')` instead, matching the sibling `wslRemoteAgentHostService` / `sshRemoteAgentHostService` pattern. Also addresses the code-review note that `resolveCodexDevSdkRoot()` was untested: export it with an injectable resolver seam (consistent with the already-exported, already-tested `codexPackageSuffix`/`codexBinaryTriple` in the same file) and add unit tests for the resolvable and throwing cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/codex/codexAgent.ts | 23 +++++++++++++++---- .../test/node/codex/codexPackagePaths.test.ts | 20 +++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 7679d33d2138d..0d86dc41cbf53 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -5,7 +5,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; -import { createRequire } from 'node:module'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; @@ -806,7 +805,7 @@ export class CodexAgent extends Disposable implements IAgent { if (this._agentSdkDownloader.isAvailable(CodexSdkPackage)) { return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); } - const devRoot = resolveCodexDevSdkRoot(); + const devRoot = await resolveCodexDevSdkRoot(); if (devRoot) { this._logService.info(`[Codex] resolving SDK from repo node_modules (dev fallback): ${devRoot}`); return devRoot; @@ -2623,14 +2622,28 @@ export function codexBinaryTriple(sdkTarget: string): string | undefined { * onto), or undefined when the package can't be resolved (e.g. a built product * where it isn't shipped). `@openai/codex` declares no `exports` map, so its * `package.json` is resolvable. + * + * `resolvePackageJsonPath` is a seam for tests; production resolves the path + * via {@link defaultResolveCodexPackageJsonPath}. */ -function resolveCodexDevSdkRoot(): string | undefined { +export async function resolveCodexDevSdkRoot( + resolvePackageJsonPath: () => string | Promise = defaultResolveCodexPackageJsonPath, +): Promise { try { - const nodeRequire = createRequire(import.meta.url); - const pkgJson = nodeRequire.resolve('@openai/codex/package.json'); + const pkgJson = await resolvePackageJsonPath(); // /node_modules/@openai/codex/package.json → return dirname(dirname(dirname(dirname(pkgJson)))); } catch { return undefined; } } + +async function defaultResolveCodexPackageJsonPath(): Promise { + // Dynamic import of `node:module` (not a static top-level import): the + // unit-test electron renderer that loads this module for + // `codexPackagePaths.test` cannot fetch a static `node:module` import, so + // the sibling WSL/SSH host services resolve `createRequire` the same way + // for the same reason. + const { createRequire } = await import('node:module'); + return createRequire(import.meta.url).resolve('@openai/codex/package.json'); +} diff --git a/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts index db3a5a1c282b5..db22310833673 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { join } from '../../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { codexBinaryTriple, codexPackageSuffix } from '../../../node/codex/codexAgent.js'; +import { codexBinaryTriple, codexPackageSuffix, resolveCodexDevSdkRoot } from '../../../node/codex/codexAgent.js'; suite('codex package paths', () => { @@ -84,4 +85,21 @@ suite('codex package paths', () => { assert.strictEqual(codexBinaryTriple(''), undefined); }); }); + + suite('resolveCodexDevSdkRoot', () => { + + test('returns the directory containing node_modules when @openai/codex resolves', async () => { + // `require.resolve('@openai/codex/package.json')` yields + // `/node_modules/@openai/codex/package.json`; the helper walks + // four segments up to recover `` — the dir `_startConnection` + // joins `node_modules/@openai/codex-` onto. + const root = join('home', 'me', 'vscode'); + const pkgJson = join(root, 'node_modules', '@openai', 'codex', 'package.json'); + assert.strictEqual(await resolveCodexDevSdkRoot(() => pkgJson), root); + }); + + test('returns undefined when resolution throws (e.g. built product without the devDependency)', async () => { + assert.strictEqual(await resolveCodexDevSdkRoot(() => { throw new Error('Cannot find module'); }), undefined); + }); + }); }); From fcf74c4a1e9bd0fd7cc86107186bd72b0b0216a0 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 22 Jun 2026 15:50:06 +0200 Subject: [PATCH 005/696] smoke: require Codex session test in dev, skip only in built products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that Codex resolves from the repo's `node_modules` in dev, the Codex smoke test must always run when running from source instead of skipping when the session type is unavailable. Make it a hard requirement in `Quality.Dev` (throw if Codex is unexpectedly unavailable) and keep the graceful skip only for built products, where the SDK is not shipped (devDependencies are stripped) and is fetched from `product.agentSdks.codex` on publish builds only — so non-publish CI builds legitimately have no Codex. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../areas/agentsWindow/agentsWindow.test.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index a7dbed718ebcc..60f2aee8eaf7b 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { Application, ApplicationOptions, Logger } from '../../../../automation'; +import { Application, ApplicationOptions, Logger, Quality } from '../../../../automation'; import { createApp, dumpFailureDiagnostics, getCopilotSmokeTestEnv, getMockLlmServerPath, installAppAfterHandler, installDiagnosticsHandler, installAllHandlers, MockLlmServer, suiteCrashPath, suiteLogsPath } from '../../utils'; // Selector for the send button in the Agents Window new-session homepage. @@ -575,10 +575,11 @@ export function setup(logger: Logger) { }, settings: { // Register the Codex provider in the agent host process (it is - // off by default). The provider only actually appears if the - // codex SDK is resolvable (product.agentSdks.codex in packaged - // builds, or VSCODE_AGENT_HOST_CODEX_SDK_ROOT in dev) — the test - // skips gracefully when it is not. + // off by default). The provider resolves the codex SDK from the + // repo's `node_modules` in dev, or `product.agentSdks.codex` in + // packaged builds (or the VSCODE_AGENT_HOST_CODEX_SDK_ROOT + // override) — so the test below is a hard requirement in dev and + // skips only in built products where the SDK is genuinely absent. 'chat.agentHost.codexAgent.enabled': true, }, }); @@ -588,15 +589,23 @@ export function setup(logger: Logger) { const app = this.app as Application; - // Gate on Codex availability OUTSIDE the try/catch below so that the + // Resolve Codex availability OUTSIDE the try/catch below so that the // Pending thrown by `this.skip()` is not swallowed (and re-thrown as a - // failure) by the failure-diagnostics handler. Codex registers only - // when its native SDK is resolvable; until it ships in the build this - // keeps the suite green instead of red. + // failure) by the failure-diagnostics handler. await app.workbench.agentsWindow.waitForNewSessionView(); const codexAvailable = await app.workbench.agentsWindow.isSessionTypeAvailable('Codex'); if (!codexAvailable) { - logger.log('[Agents Window/Codex] Codex session type not available (no product.agentSdks.codex / VSCODE_AGENT_HOST_CODEX_SDK_ROOT); skipping'); + // In dev (running from source) Codex resolves from the repo's + // `node_modules` — `@openai/codex` is a devDependency — so it must + // always be available; fail loudly rather than skip. In built + // products the SDK is not shipped (devDependencies are stripped) + // and is fetched from `product.agentSdks.codex` only on publish + // builds, so skip gracefully when it is genuinely absent (e.g. + // non-publish CI builds). + if (app.quality === Quality.Dev) { + throw new Error('[Agents Window/Codex] Codex session type unexpectedly unavailable in dev — the node_modules SDK fallback should make it resolvable'); + } + logger.log('[Agents Window/Codex] Codex session type not available in this built product (no product.agentSdks.codex); skipping'); this.skip(); } From 6f10471d3e54cb372fb09aa149540d0c51fe1dd7 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 22 Jun 2026 16:19:16 +0200 Subject: [PATCH 006/696] smoke: also require Codex session test on publish builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the Codex smoke test was a hard requirement only in dev and skipped on all built products. But publish builds stamp `product.agentSdks.codex` (only when VSCODE_PUBLISH=true) and resolve the SDK from the CDN, so Codex should be available there too — skipping would hide a CDN-resolution regression. Fail when the build is supposed to resolve Codex (dev OR VSCODE_PUBLISH=true), and skip only on built non-publish CI builds where the SDK is neither shipped nor stamped. VSCODE_PUBLISH is an Azure pipeline variable auto-injected into the smoke step env; it is absent in dev (GitHub Actions) runs, which the `Quality.Dev` branch already covers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../areas/agentsWindow/agentsWindow.test.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index 60f2aee8eaf7b..f05de8543a3b3 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -595,15 +595,20 @@ export function setup(logger: Logger) { await app.workbench.agentsWindow.waitForNewSessionView(); const codexAvailable = await app.workbench.agentsWindow.isSessionTypeAvailable('Codex'); if (!codexAvailable) { - // In dev (running from source) Codex resolves from the repo's - // `node_modules` — `@openai/codex` is a devDependency — so it must - // always be available; fail loudly rather than skip. In built - // products the SDK is not shipped (devDependencies are stripped) - // and is fetched from `product.agentSdks.codex` only on publish - // builds, so skip gracefully when it is genuinely absent (e.g. - // non-publish CI builds). - if (app.quality === Quality.Dev) { - throw new Error('[Agents Window/Codex] Codex session type unexpectedly unavailable in dev — the node_modules SDK fallback should make it resolvable'); + // Codex must be available — and so this test must run rather than + // skip — whenever the build under test is supposed to be able to + // resolve the SDK: + // - Dev (running from source): resolves from the repo's + // `node_modules` (`@openai/codex` is a devDependency). + // - Publish builds: `product.agentSdks.codex` is stamped (only + // when VSCODE_PUBLISH=true, see build/azure-pipelines/common/ + // agent-sdk-produce.yml) so the SDK is fetched from the CDN. + // In both cases an unavailable Codex is a regression — fail loudly. + // Otherwise (built non-publish CI, where the SDK is neither shipped + // nor stamped) Codex is legitimately absent, so skip gracefully. + const isPublishBuild = (process.env['VSCODE_PUBLISH'] ?? '').toLowerCase() === 'true'; + if (app.quality === Quality.Dev || isPublishBuild) { + throw new Error(`[Agents Window/Codex] Codex session type unexpectedly unavailable (quality=${app.quality}, VSCODE_PUBLISH=${process.env['VSCODE_PUBLISH'] ?? ''}) — the SDK should be resolvable from node_modules (dev) or product.agentSdks.codex (publish build)`); } logger.log('[Agents Window/Codex] Codex session type not available in this built product (no product.agentSdks.codex); skipping'); this.skip(); From ea6bc378f9e9f80d39e64e9dc25cc429438bba85 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 22 Jun 2026 16:21:56 +0200 Subject: [PATCH 007/696] smoke: use VSCODE_DEV (not Quality.Dev) to detect from-source Codex run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app.quality === Quality.Dev` is not a precise "running from source" signal: `parseQuality()` also returns `Quality.Dev` for a `--build` product when `VSCODE_QUALITY` is unset, which would wrongly hard-fail a packaged build that legitimately lacks Codex. Use `process.env.VSCODE_DEV === '1'` instead — the smoke runner sets it deterministically only in the no-`--build` (dev electron) branch, mirroring the agent host's `isBuilt === false` that gates the node_modules fallback. This matches existing dev-detection in main.ts and the diagnostics already logged in this file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../areas/agentsWindow/agentsWindow.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index f05de8543a3b3..e2fe4b001f092 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { Application, ApplicationOptions, Logger, Quality } from '../../../../automation'; +import { Application, ApplicationOptions, Logger } from '../../../../automation'; import { createApp, dumpFailureDiagnostics, getCopilotSmokeTestEnv, getMockLlmServerPath, installAppAfterHandler, installDiagnosticsHandler, installAllHandlers, MockLlmServer, suiteCrashPath, suiteLogsPath } from '../../utils'; // Selector for the send button in the Agents Window new-session homepage. @@ -598,17 +598,25 @@ export function setup(logger: Logger) { // Codex must be available — and so this test must run rather than // skip — whenever the build under test is supposed to be able to // resolve the SDK: - // - Dev (running from source): resolves from the repo's - // `node_modules` (`@openai/codex` is a devDependency). + // - Running from source (VSCODE_DEV=1, set by the smoke runner + // when no `--build` is passed): the agent host is not built, so + // it resolves the SDK from the repo's `node_modules` + // (`@openai/codex` is a devDependency). // - Publish builds: `product.agentSdks.codex` is stamped (only // when VSCODE_PUBLISH=true, see build/azure-pipelines/common/ // agent-sdk-produce.yml) so the SDK is fetched from the CDN. // In both cases an unavailable Codex is a regression — fail loudly. // Otherwise (built non-publish CI, where the SDK is neither shipped // nor stamped) Codex is legitimately absent, so skip gracefully. + // + // VSCODE_DEV (not app.quality === Quality.Dev) is the precise + // "from source" signal: parseQuality() also returns Quality.Dev for + // a `--build` product when VSCODE_QUALITY is unset, which would + // wrongly hard-fail a packaged build that legitimately lacks Codex. + const isFromSource = process.env['VSCODE_DEV'] === '1'; const isPublishBuild = (process.env['VSCODE_PUBLISH'] ?? '').toLowerCase() === 'true'; - if (app.quality === Quality.Dev || isPublishBuild) { - throw new Error(`[Agents Window/Codex] Codex session type unexpectedly unavailable (quality=${app.quality}, VSCODE_PUBLISH=${process.env['VSCODE_PUBLISH'] ?? ''}) — the SDK should be resolvable from node_modules (dev) or product.agentSdks.codex (publish build)`); + if (isFromSource || isPublishBuild) { + throw new Error(`[Agents Window/Codex] Codex session type unexpectedly unavailable (VSCODE_DEV=${process.env['VSCODE_DEV'] ?? ''}, VSCODE_PUBLISH=${process.env['VSCODE_PUBLISH'] ?? ''}) — the SDK should be resolvable from node_modules (from source) or product.agentSdks.codex (publish build)`); } logger.log('[Agents Window/Codex] Codex session type not available in this built product (no product.agentSdks.codex); skipping'); this.skip(); From f7c07eeb5cc1305c877f5f2a09799d99687d05d6 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 22 Jun 2026 19:30:18 -0700 Subject: [PATCH 008/696] Add proxy service, translation, and tests --- .../agentHost/common/agentHostByokLm.ts | 103 +++++++ .../agentHost/node/byokLmBridgeRegistry.ts | 66 +++++ .../node/copilot/byokLmProxyService.ts | 258 ++++++++++++++++++ .../node/copilot/byokOpenAiTranslation.ts | 234 ++++++++++++++++ .../test/node/byokLmProxyService.test.ts | 243 +++++++++++++++++ .../test/node/byokOpenAiTranslation.test.ts | 105 +++++++ 6 files changed, 1009 insertions(+) create mode 100644 src/vs/platform/agentHost/common/agentHostByokLm.ts create mode 100644 src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts create mode 100644 src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts create mode 100644 src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts create mode 100644 src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts create mode 100644 src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts new file mode 100644 index 0000000000000..c454a562e813a --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostByokLm.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +/** + * Serializable bridge contract between the node agent host (where the + * {@link IByokLmProxyService} OpenAI-compatible proxy runs) and the renderer + * (which owns the extension-provided BYOK language models via the LM API). + * + * These shapes are deliberately wire-friendly (plain JSON, no `VSBuffer`, + * `URI`, or `workbench/contrib/chat` types) so they survive both the local + * utility-process IPC channel and the remote JSON-RPC transport without a + * translation step. The node side converts OpenAI Chat Completions wire + * payloads to/from these; the renderer side converts these to/from the VS Code + * LM API (`ILanguageModelsService`). + */ + +/** A single tool/function call requested by the assistant. */ +export interface IByokLmToolCall { + /** Stable id correlating the call with its later `tool` result message. */ + readonly id: string; + /** Tool/function name. */ + readonly name: string; + /** JSON-encoded arguments object. */ + readonly argumentsJson: string; +} + +/** A tool/function the model may call. */ +export interface IByokLmTool { + readonly name: string; + readonly description?: string; + /** JSON schema for the tool parameters. */ + readonly parametersSchema?: object; +} + +/** One chat message in a BYOK request. */ +export interface IByokLmChatMessage { + readonly role: 'system' | 'user' | 'assistant' | 'tool'; + /** Flattened text content. Empty string when the message carries only tool calls/results. */ + readonly content: string; + /** Present on `assistant` messages that requested tool calls. */ + readonly toolCalls?: IByokLmToolCall[]; + /** Present on `tool` messages: the {@link IByokLmToolCall.id} this result answers. */ + readonly toolCallId?: string; +} + +/** A chat request forwarded from the proxy to the renderer LM API. */ +export interface IByokLmChatRequest { + /** Provider/vendor name (the LM API vendor that registered the model). */ + readonly vendor: string; + /** Provider-local model id (the wire id the runtime sent on the OpenAI request). */ + readonly modelId: string; + readonly messages: IByokLmChatMessage[]; + readonly tools?: IByokLmTool[]; + /** Opaque per-request model options forwarded to the LM provider. */ + readonly modelOptions?: Record; +} + +/** The (buffered) completion produced by the renderer LM API. */ +export interface IByokLmChatResult { + /** Concatenated assistant text. */ + readonly content: string; + /** Tool calls the assistant requested, if any. */ + readonly toolCalls?: IByokLmToolCall[]; + /** Best-effort token usage, when the provider reports it. */ + readonly usage?: { + readonly promptTokens?: number; + readonly completionTokens?: number; + }; + /** Set when the LM call failed; `content` is then empty. */ + readonly error?: string; +} + +export const IAgentHostByokLmHandler = createDecorator('agentHostByokLmHandler'); + +/** + * Renderer-side handler that services {@link IByokLmChatRequest}s by calling + * the VS Code Language Model API. Implemented in the workbench (where + * `ILanguageModelsService` lives) and reached from the node agent host over + * the reverse bridge. + */ +export interface IAgentHostByokLmHandler { + readonly _serviceBrand: undefined; + + /** + * Run a BYOK chat completion against the extension-registered model that + * matches `request.vendor` + `request.modelId`. Rejects (or resolves with + * {@link IByokLmChatResult.error}) when no such model is available. + */ + chat(request: IByokLmChatRequest, token: CancellationToken): Promise; +} + +/** + * Node-side connection to a single renderer's {@link IAgentHostByokLmHandler}. + * Mirrors `IRemoteFilesystemConnection` for the reverse FS bridge. + */ +export interface IByokLmBridgeConnection { + chat(request: IByokLmChatRequest): Promise; +} diff --git a/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts new file mode 100644 index 0000000000000..4bd15ffdc684f --- /dev/null +++ b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.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 { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { IByokLmBridgeConnection } from '../common/agentHostByokLm.js'; + +export const IByokLmBridgeRegistry = createDecorator('byokLmBridgeRegistry'); + +/** + * Node-side registry of renderer {@link IByokLmBridgeConnection}s keyed by + * client id. Populated by the agent host's connection lifecycle (one entry per + * connected renderer) and consumed by {@link IByokLmProxyService} when it needs + * to service an inbound OpenAI request against the renderer LM API. + * + * Single-tenant assumption (matching the Claude/Codex proxies): the most + * recently registered connection is treated as the active one. + */ +export interface IByokLmBridgeRegistry { + readonly _serviceBrand: undefined; + + /** Register a renderer connection. Disposing the result removes it. */ + register(clientId: string, connection: IByokLmBridgeConnection): IDisposable; + + /** The connection for `clientId`, or `undefined` if none is registered. */ + get(clientId: string): IByokLmBridgeConnection | undefined; + + /** The most recently registered, still-live connection, if any. */ + getActive(): IByokLmBridgeConnection | undefined; +} + +export class ByokLmBridgeRegistry implements IByokLmBridgeRegistry { + + declare readonly _serviceBrand: undefined; + + private readonly _connections = new Map(); + private _activeClientId: string | undefined; + + register(clientId: string, connection: IByokLmBridgeConnection): IDisposable { + this._connections.set(clientId, connection); + this._activeClientId = clientId; + return toDisposable(() => { + if (this._connections.get(clientId) === connection) { + this._connections.delete(clientId); + if (this._activeClientId === clientId) { + // Fall back to any remaining connection. + const next = this._connections.keys().next(); + this._activeClientId = next.done ? undefined : next.value; + } + } + }); + } + + get(clientId: string): IByokLmBridgeConnection | undefined { + return this._connections.get(clientId); + } + + getActive(): IByokLmBridgeConnection | undefined { + if (this._activeClientId !== undefined) { + return this._connections.get(this._activeClientId); + } + return undefined; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts new file mode 100644 index 0000000000000..a2a2e3e05d81b --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts @@ -0,0 +1,258 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as http from 'http'; +import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +import { parseProxyBearer } from '../claude/claudeProxyAuth.js'; +import { + ILoopbackProxyHandle, + ILoopbackProxyRuntime, + IProxyInFlight, + LoopbackProxyServer, + readProxyRequestBody, +} from '../shared/loopbackProxyServer.js'; +import { + IOpenAiChatRequest, + OpenAiTranslationError, + bridgeResultToSseFrames, + openAiErrorBody, + openAiRequestToBridge, +} from './byokOpenAiTranslation.js'; + +// #region Public types + +/** + * Handle returned by {@link IByokLmProxyService.start}. Refcounts the shared + * loopback server (see {@link LoopbackProxyServer}): when every handle is + * disposed the listener closes and the nonce is destroyed; the next `start()` + * rebinds with a fresh port and nonce. + * + * **Subprocess ownership invariant.** Callers that hand `baseUrl`/`nonce` to + * the Copilot SDK runtime subprocess MUST kill that subprocess before calling + * `dispose()` — after disposal the proxy may rebind on a different port and the + * subprocess would silently lose its endpoint (same contract as the Claude and + * Codex proxies). + */ +export interface IByokLmProxyHandle extends ILoopbackProxyHandle { + /** e.g. `http://127.0.0.1:54321` — no trailing slash. */ + readonly baseUrl: string; + /** 256-bit hex string. Combine with a session id as `Bearer .`. */ + readonly nonce: string; + /** + * Build the provider `baseUrl` for a given BYOK vendor. The vendor is + * encoded into the path so a single proxy can serve every vendor; the + * runtime appends `/chat/completions` to this URL. + */ + providerBaseUrl(vendor: string): string; +} + +export const IByokLmProxyService = createDecorator('byokLmProxyService'); + +export interface IByokLmProxyService { + readonly _serviceBrand: undefined; + + /** Start the proxy (if not already running) and return a refcounted handle. */ + start(): Promise; + + /** + * Force-close the proxy regardless of refcount and abort in-flight + * requests. Idempotent; subsequent `start()` calls rebind. + */ + dispose(): void; +} + +// #endregion + +const PROXY_USER_FACING_NAME = 'ByokLmProxyService'; +const VENDOR_PATH_PREFIX = '/v/'; +const CHAT_COMPLETIONS_SUFFIX = '/chat/completions'; + +/** + * The BYOK proxy keeps no per-bind mutable state: the active renderer bridge is + * resolved from {@link IByokLmBridgeRegistry} at request time, and the nonce + * lives on the runtime owned by {@link LoopbackProxyServer}. + */ +type ByokLmProxyState = undefined; + +/** + * Local OpenAI-compatible HTTP proxy that lets the Copilot SDK runtime run + * BYOK models provided by VS Code extensions. The runtime is configured with a + * `type: 'openai'`, `wireApi: 'completions'` provider whose `baseUrl` points + * here; inbound `POST /v//chat/completions` requests are authenticated, + * translated, and forwarded to the renderer LM API via + * {@link IByokLmBridgeRegistry}, and the buffered completion is streamed back + * as OpenAI Chat Completions SSE. + * + * The server lifecycle — lazy bind on `127.0.0.1`, nonce minting, refcounted + * handles, in-flight tracking, and teardown — is inherited from + * {@link LoopbackProxyServer}; this subclass only implements request routing. + */ +export class ByokLmProxyService extends LoopbackProxyServer implements IByokLmProxyService { + + declare readonly _serviceBrand: undefined; + + constructor( + @ILogService logService: ILogService, + @IByokLmBridgeRegistry private readonly _bridgeRegistry: IByokLmBridgeRegistry, + ) { + super(PROXY_USER_FACING_NAME, logService); + } + + protected createState(): ByokLmProxyState { + // No per-bind state — the bridge is resolved from the registry per request. + return undefined; + } + + async start(): Promise { + const { runtime, release } = await this.acquire(); + + let disposed = false; + return { + baseUrl: runtime.baseUrl, + nonce: runtime.nonce, + providerBaseUrl: (vendor: string) => `${runtime.baseUrl}${VENDOR_PATH_PREFIX}${encodeURIComponent(vendor)}`, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + release(); + }, + }; + } + + /** Emit the base's fallback failure using the OpenAI error envelope. */ + protected override writeInternalError(res: http.ServerResponse): void { + this._writeJsonError(res, 500, 'Internal proxy error'); + } + + protected override async handleRequest(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime): Promise { + const method = req.method ?? 'GET'; + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + this._logService.trace(`[${PROXY_USER_FACING_NAME}] ${method} ${pathname}`); + + if (method === 'GET' && pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + return; + } + + // Inbound requests carry `Bearer .`; the runtime is + // handed `.` at session launch. + const auth = parseProxyBearer(req.headers, runtime.nonce); + if (!auth.valid || !auth.sessionId) { + this._writeJsonError(res, 401, 'Invalid authentication', 'authentication_error'); + return; + } + + const vendor = this._parseVendorFromChatPath(pathname); + if (method === 'POST' && vendor !== undefined) { + await this._handleChatCompletions(req, res, runtime, vendor); + return; + } + + this._writeJsonError(res, 404, `No route for ${method} ${pathname}`, 'not_found_error'); + } + + /** + * Extract the vendor from a `/v//chat/completions` path, or return + * `undefined` when the path is not a chat-completions route. + */ + private _parseVendorFromChatPath(pathname: string): string | undefined { + if (!pathname.startsWith(VENDOR_PATH_PREFIX) || !pathname.endsWith(CHAT_COMPLETIONS_SUFFIX)) { + return undefined; + } + const vendorSegment = pathname.slice(VENDOR_PATH_PREFIX.length, pathname.length - CHAT_COMPLETIONS_SUFFIX.length); + if (!vendorSegment || vendorSegment.includes('/')) { + return undefined; + } + try { + return decodeURIComponent(vendorSegment); + } catch { + return undefined; + } + } + + private async _handleChatCompletions(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime, vendor: string): Promise { + const connection = this._bridgeRegistry.getActive(); + if (!connection) { + this._writeJsonError(res, 503, 'No renderer connection available to service BYOK models', 'api_error'); + return; + } + + let body: IOpenAiChatRequest; + try { + const raw = await readProxyRequestBody(req); + body = JSON.parse(raw) as IOpenAiChatRequest; + } catch (err) { + this._writeJsonError(res, 400, `Invalid request body: ${err instanceof Error ? err.message : String(err)}`, 'invalid_request_error'); + return; + } + + let bridgeRequest; + try { + bridgeRequest = openAiRequestToBridge(vendor, body); + } catch (err) { + const message = err instanceof OpenAiTranslationError ? err.message : String(err); + this._writeJsonError(res, 400, message, 'invalid_request_error'); + return; + } + + // Register the request so {@link LoopbackProxyServer} aborts it on + // teardown; a client-side disconnect also flips `clientGone` and aborts. + // Both surface through the shared `AbortController`, which we re-check + // after the async bridge hop before touching the response. + const entry: IProxyInFlight = { ac: new AbortController(), res, clientGone: false }; + runtime.inFlight.add(entry); + const onClose = () => { + entry.clientGone = true; + entry.ac.abort(); + }; + res.on('close', onClose); + + try { + const result = await connection.chat(bridgeRequest); + if (entry.ac.signal.aborted || res.writableEnded) { + return; + } + if (result.error) { + this._writeJsonError(res, 502, result.error, 'api_error'); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + for (const frame of bridgeResultToSseFrames(result, bridgeRequest.modelId)) { + res.write(frame); + } + res.end(); + } catch (err) { + if (entry.ac.signal.aborted || res.writableEnded) { + return; + } + const message = err instanceof Error ? err.message : String(err); + if (!res.headersSent) { + this._writeJsonError(res, 502, message, 'api_error'); + } else { + try { res.end(); } catch { /* ignore */ } + } + } finally { + res.removeListener('close', onClose); + runtime.inFlight.delete(entry); + } + } + + private _writeJsonError(res: http.ServerResponse, status: number, message: string, type = 'api_error'): void { + if (res.headersSent || res.writableEnded) { + return; + } + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(openAiErrorBody(message, type)); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts b/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts new file mode 100644 index 0000000000000..39457177d6a13 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + IByokLmChatMessage, + IByokLmChatRequest, + IByokLmChatResult, + IByokLmTool, + IByokLmToolCall, +} from '../../common/agentHostByokLm.js'; + +/** + * Minimal subset of the OpenAI Chat Completions wire format the Copilot SDK + * runtime emits for a `type: 'openai'`, `wireApi: 'completions'` provider + * (verified against the runtime's `chat_completion_transport.rs`, which POSTs + * to `{baseUrl}/chat/completions`). Only the fields this proxy understands are + * modeled; unknown fields are ignored. + */ + +interface IOpenAiTextContentPart { + readonly type: 'text'; + readonly text: string; +} + +type IOpenAiContentPart = IOpenAiTextContentPart | { readonly type: string;[k: string]: unknown }; + +interface IOpenAiToolCall { + readonly id?: string; + readonly type?: string; + readonly function?: { + readonly name?: string; + readonly arguments?: string; + }; +} + +interface IOpenAiRequestMessage { + readonly role?: string; + readonly content?: string | IOpenAiContentPart[] | null; + readonly tool_calls?: IOpenAiToolCall[]; + readonly tool_call_id?: string; +} + +interface IOpenAiToolDefinition { + readonly type?: string; + readonly function?: { + readonly name?: string; + readonly description?: string; + readonly parameters?: object; + }; +} + +export interface IOpenAiChatRequest { + readonly model?: string; + readonly messages?: IOpenAiRequestMessage[]; + readonly tools?: IOpenAiToolDefinition[]; + readonly stream?: boolean; + readonly temperature?: number; + readonly top_p?: number; + readonly max_tokens?: number; + readonly [k: string]: unknown; +} + +/** Thrown when the inbound body cannot be mapped to a bridge request. */ +export class OpenAiTranslationError extends Error { } + +function flattenContent(content: string | IOpenAiContentPart[] | null | undefined): string { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + let out = ''; + for (const part of content) { + if (part && part.type === 'text' && typeof (part as IOpenAiTextContentPart).text === 'string') { + out += (part as IOpenAiTextContentPart).text; + } + } + return out; + } + return ''; +} + +function toBridgeRole(role: string | undefined): IByokLmChatMessage['role'] { + switch (role) { + case 'system': + case 'developer': + return 'system'; + case 'assistant': + return 'assistant'; + case 'tool': + case 'function': + return 'tool'; + case 'user': + default: + return 'user'; + } +} + +function toBridgeToolCalls(toolCalls: IOpenAiToolCall[] | undefined): IByokLmToolCall[] | undefined { + if (!toolCalls || toolCalls.length === 0) { + return undefined; + } + const mapped: IByokLmToolCall[] = []; + for (let i = 0; i < toolCalls.length; i++) { + const call = toolCalls[i]; + mapped.push({ + id: call.id ?? `call_${i}`, + name: call.function?.name ?? '', + argumentsJson: call.function?.arguments ?? '{}', + }); + } + return mapped; +} + +function toBridgeTools(tools: IOpenAiToolDefinition[] | undefined): IByokLmTool[] | undefined { + if (!tools || tools.length === 0) { + return undefined; + } + const mapped: IByokLmTool[] = []; + for (const tool of tools) { + const fn = tool.function; + if (!fn?.name) { + continue; + } + mapped.push({ + name: fn.name, + description: fn.description, + parametersSchema: fn.parameters, + }); + } + return mapped.length ? mapped : undefined; +} + +/** + * Convert a parsed OpenAI Chat Completions request into the serializable + * bridge request. `vendor` is the synthesized provider name the runtime used + * (it is not present in the OpenAI body); `model` becomes the provider-local + * wire model id resolved on the renderer. + */ +export function openAiRequestToBridge(vendor: string, body: IOpenAiChatRequest): IByokLmChatRequest { + const model = typeof body.model === 'string' ? body.model : ''; + if (!model) { + throw new OpenAiTranslationError('Request is missing the "model" field'); + } + const sourceMessages = Array.isArray(body.messages) ? body.messages : []; + const messages: IByokLmChatMessage[] = sourceMessages.map(message => ({ + role: toBridgeRole(message.role), + content: flattenContent(message.content), + toolCalls: toBridgeToolCalls(message.tool_calls), + toolCallId: message.tool_call_id, + })); + + const modelOptions: Record = {}; + if (typeof body.temperature === 'number') { + modelOptions.temperature = body.temperature; + } + if (typeof body.top_p === 'number') { + modelOptions.top_p = body.top_p; + } + if (typeof body.max_tokens === 'number') { + modelOptions.max_tokens = body.max_tokens; + } + + return { + vendor, + modelId: model, + messages, + tools: toBridgeTools(body.tools), + modelOptions: Object.keys(modelOptions).length ? modelOptions : undefined, + }; +} + +let chunkCounter = 0; + +function nextCompletionId(): string { + chunkCounter = (chunkCounter + 1) % Number.MAX_SAFE_INTEGER; + return `chatcmpl-byok-${Date.now().toString(36)}-${chunkCounter.toString(36)}`; +} + +/** Serialize a single SSE `data:` frame. */ +function sseFrame(payload: unknown): string { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +/** + * Encode a buffered {@link IByokLmChatResult} as a sequence of OpenAI + * `chat.completion.chunk` SSE frames terminated by `data: [DONE]`. + * + * The whole completion is emitted in one content delta (Stage 1 is + * non-streaming end-to-end); the runtime's SSE parser accepts this shape. + */ +export function bridgeResultToSseFrames(result: IByokLmChatResult, model: string): string[] { + const id = nextCompletionId(); + const created = Math.floor(Date.now() / 1000); + const base = { id, object: 'chat.completion.chunk', created, model }; + const frames: string[] = []; + + // Role delta first, matching the OpenAI streaming contract. + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })); + + if (result.content) { + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { content: result.content }, finish_reason: null }] })); + } + + let finishReason: 'stop' | 'tool_calls' = 'stop'; + if (result.toolCalls && result.toolCalls.length > 0) { + finishReason = 'tool_calls'; + const toolCallsDelta = result.toolCalls.map((call, index) => ({ + index, + id: call.id, + type: 'function', + function: { name: call.name, arguments: call.argumentsJson }, + })); + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { tool_calls: toolCallsDelta }, finish_reason: null }] })); + } + + const finalChunk: Record = { ...base, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] }; + if (result.usage) { + finalChunk.usage = { + prompt_tokens: result.usage.promptTokens ?? 0, + completion_tokens: result.usage.completionTokens ?? 0, + total_tokens: (result.usage.promptTokens ?? 0) + (result.usage.completionTokens ?? 0), + }; + } + frames.push(sseFrame(finalChunk)); + frames.push('data: [DONE]\n\n'); + return frames; +} + +/** Build an OpenAI-style error envelope body. */ +export function openAiErrorBody(message: string, type = 'api_error'): string { + return JSON.stringify({ error: { message, type } }); +} diff --git a/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts new file mode 100644 index 0000000000000..da608108d5cdf --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../log/common/log.js'; +import type { IByokLmChatRequest, IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; + +/** + * Exercises the inference path end-to-end without the Copilot SDK runtime: + * the test plays the runtime's role by POSTing OpenAI Chat Completions + * requests at the loopback proxy, and plays the renderer's role with a fake + * {@link IByokLmChatRequest} -> {@link IByokLmChatResult} bridge function. The + * only contract under test is the OpenAI wire format in, the bridge DTO out, + * and the SSE wire format back. + */ +suite('ByokLmProxyService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + + async function withProxy( + chat: (request: IByokLmChatRequest) => Promise, + run: (handle: IByokLmProxyHandle) => Promise, + ): Promise { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', { chat }); + const service = new ByokLmProxyService(new NullLogService(), registry); + const handle = await service.start(); + try { + await run(handle); + } finally { + handle.dispose(); + registration.dispose(); + service.dispose(); + } + } + + function chatUrl(handle: IByokLmProxyHandle, vendor: string): string { + return `${handle.providerBaseUrl(vendor)}/chat/completions`; + } + + function authHeaders(handle: IByokLmProxyHandle): Record { + return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}.${sessionId}` }; + } + + test('serves the unauthenticated health check', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(`${handle.baseUrl}/`); + assert.strictEqual(response.status, 200); + assert.strictEqual(await response.text(), 'ok'); + }, + ); + }); + + test('rejects requests without a valid bearer token', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 401); + }, + ); + }); + + test('rejects a nonce-only bearer token (no session id)', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}` }, + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 401); + }, + ); + }); + + test('returns 404 for an authenticated but unknown route', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(`${handle.baseUrl}/v/acme/responses`, { + method: 'POST', + headers: authHeaders(handle), + body: '{}', + }); + assert.strictEqual(response.status, 404); + }, + ); + }); + + test('forwards a chat request to the bridge and streams an SSE completion', async () => { + let captured: IByokLmChatRequest | undefined; + await withProxy( + async (request) => { + captured = request; + return { content: 'hello from byok' }; + }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'claude', messages: [{ role: 'user', content: 'hi' }] }), + }); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('content-type'), 'text/event-stream'); + const text = await response.text(); + assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); + assert.ok(text.trimEnd().endsWith('data: [DONE]')); + }, + ); + assert.strictEqual(captured?.vendor, 'acme'); + assert.strictEqual(captured?.modelId, 'claude'); + assert.deepStrictEqual(captured?.messages, [{ role: 'user', content: 'hi', toolCalls: undefined, toolCallId: undefined }]); + }); + + test('decodes a url-encoded vendor path segment', async () => { + let captured: IByokLmChatRequest | undefined; + await withProxy( + async (request) => { captured = request; return { content: 'ok' }; }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme corp'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 200); + await response.text(); + }, + ); + assert.strictEqual(captured?.vendor, 'acme corp'); + }); + + test('streams assistant tool calls as OpenAI tool_call deltas', async () => { + await withProxy( + async () => ({ content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [{ role: 'user', content: 'weather?' }] }), + }); + const text = await response.text(); + assert.ok(text.includes('"tool_calls"'), `expected tool_calls in SSE: ${text}`); + assert.ok(text.includes('"finish_reason":"tool_calls"'), `expected tool_calls finish reason: ${text}`); + assert.ok(text.includes('getWeather')); + }, + ); + }); + + test('returns a 502 when the bridge reports an error', async () => { + await withProxy( + async () => ({ content: '', error: 'model unavailable' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 502); + const body = await response.json() as { error?: { message?: string } }; + assert.strictEqual(body.error?.message, 'model unavailable'); + }, + ); + }); + + test('returns a 502 when the bridge throws', async () => { + await withProxy( + async () => { throw new Error('bridge exploded'); }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 502); + const body = await response.json() as { error?: { message?: string } }; + assert.strictEqual(body.error?.message, 'bridge exploded'); + }, + ); + }); + + test('rejects a malformed JSON body with 400', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: 'not json', + }); + assert.strictEqual(response.status, 400); + }, + ); + }); + + test('returns a 503 when no renderer bridge is connected', async () => { + const registry = new ByokLmBridgeRegistry(); + const service = new ByokLmProxyService(new NullLogService(), registry); + const handle = await service.start(); + try { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 503); + } finally { + handle.dispose(); + service.dispose(); + } + }); + + test('rebinds with a fresh nonce after every handle is disposed', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', { chat: async () => ({ content: 'ok' }) }); + const service = new ByokLmProxyService(new NullLogService(), registry); + const first = await service.start(); + const firstNonce = first.nonce; + first.dispose(); + const second = await service.start(); + try { + assert.notStrictEqual(second.nonce, firstNonce); + } finally { + second.dispose(); + registration.dispose(); + service.dispose(); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts b/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts new file mode 100644 index 0000000000000..f60b8acdc09e8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { + bridgeResultToSseFrames, + openAiRequestToBridge, + OpenAiTranslationError, + type IOpenAiChatRequest, +} from '../../node/copilot/byokOpenAiTranslation.js'; + +suite('byokOpenAiTranslation', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('openAiRequestToBridge', () => { + + test('maps roles, text content, tools and options', () => { + const body: IOpenAiChatRequest = { + model: 'claude-sonnet', + temperature: 0.5, + max_tokens: 256, + messages: [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: [{ type: 'text', text: 'hi ' }, { type: 'text', text: 'there' }] }, + { + role: 'assistant', + content: '', + tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }], + }, + { role: 'tool', tool_call_id: 'call_1', content: 'sunny' }, + ], + tools: [{ type: 'function', function: { name: 'getWeather', description: 'weather', parameters: { type: 'object' } } }], + }; + + const result = openAiRequestToBridge('acme', body); + + assert.deepStrictEqual(result, { + vendor: 'acme', + modelId: 'claude-sonnet', + messages: [ + { role: 'system', content: 'be helpful', toolCalls: undefined, toolCallId: undefined }, + { role: 'user', content: 'hi there', toolCalls: undefined, toolCallId: undefined }, + { role: 'assistant', content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], toolCallId: undefined }, + { role: 'tool', content: 'sunny', toolCalls: undefined, toolCallId: 'call_1' }, + ], + tools: [{ name: 'getWeather', description: 'weather', parametersSchema: { type: 'object' } }], + modelOptions: { temperature: 0.5, max_tokens: 256 }, + }); + }); + + test('throws when model is missing', () => { + assert.throws(() => openAiRequestToBridge('acme', { messages: [] }), OpenAiTranslationError); + }); + + test('omits tools and options when absent', () => { + const result = openAiRequestToBridge('acme', { model: 'm', messages: [{ role: 'user', content: 'hello' }] }); + assert.strictEqual(result.tools, undefined); + assert.strictEqual(result.modelOptions, undefined); + }); + }); + + suite('bridgeResultToSseFrames', () => { + + function parseFrames(frames: string[]): unknown[] { + return frames + .map(frame => frame.replace(/^data: /, '').trim()) + .filter(payload => payload !== '[DONE]') + .map(payload => JSON.parse(payload)); + } + + test('emits role, content and stop frames terminated by [DONE]', () => { + const result: IByokLmChatResult = { content: 'hello world' }; + const frames = bridgeResultToSseFrames(result, 'm'); + + assert.strictEqual(frames[frames.length - 1], 'data: [DONE]\n\n'); + const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record; finish_reason: string | null }> }>; + assert.deepStrictEqual(parsed.map(p => p.choices[0].delta), [ + { role: 'assistant' }, + { content: 'hello world' }, + {}, + ]); + assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'stop'); + }); + + test('encodes tool calls and a tool_calls finish reason', () => { + const result: IByokLmChatResult = { + content: '', + toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], + }; + const frames = bridgeResultToSseFrames(result, 'm'); + const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record; finish_reason: string | null }> }>; + + const toolDelta = parsed.find(p => p.choices[0].delta.tool_calls !== undefined); + assert.deepStrictEqual(toolDelta?.choices[0].delta.tool_calls, [ + { index: 0, id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }, + ]); + assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'tool_calls'); + }); + }); +}); From 9f272157ed859e293591d3df8c860b046b94143a Mon Sep 17 00:00:00 2001 From: LuDaShi Date: Tue, 23 Jun 2026 11:11:32 +0800 Subject: [PATCH 009/696] =?UTF-8?q?=E5=90=AF=E7=94=A8cnb=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=89=93=E5=8D=A1WebIDE=E7=9A=84=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cnb/settings.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.cnb/settings.yml b/.cnb/settings.yml index 46af23c5bb65e..3f24b3a409bb6 100644 --- a/.cnb/settings.yml +++ b/.cnb/settings.yml @@ -7,4 +7,26 @@ npc: 结束对话前礼貌地回复一行:"此事背后一定有一个天大的秘密。" 无论是日常对话还是讲解知识,你都会保持以上风格, 使用中文的『~』代替所有英文的『~』, - 用卑微并专业的语气回答接下来的问题 \ No newline at end of file + 用卑微并专业的语气回答接下来的问题 + + +# 云原生开发配置 +# 注意:读取云原生启动按钮所在页面当前分支的 .cnb/settings.yml 配置 +workspace: + launch: + # 定制云原生开发启动按钮 + button: + # 按钮名称 + name: 启动云原生开发 + # 按钮描述 + # 如果值为 null,则不显示默认描述 + description: 点击此按钮启动云原生开发环境 + # 鼠标悬浮图片:仓库图片填相对路径(如 .cnb/launch-hover.gif),外部图片填 raw 地址,最大 10MB + # hoverImage: .cnb/launch-hover.gif + # CPU 核心数,默认 8,仅对默认模板有效 + cpus: 16 + # 是否禁用默认按钮,默认 false + disabled: false + # 环境创建后是否自动打开 WebIDE,默认 false + # 未安装 openssh 时会自动打开,不受此参数影响 + autoOpenWebIDE: true \ No newline at end of file From 462f3d886dbc46475bb83cce91ced02d34ae5611 Mon Sep 17 00:00:00 2001 From: LuDaShi Date: Tue, 23 Jun 2026 11:11:32 +0800 Subject: [PATCH 010/696] =?UTF-8?q?=E5=90=AF=E7=94=A8cnb=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=89=93=E5=8D=A1WebIDE=E7=9A=84=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cnb/settings.yml | 24 +++++++++++++++++++++++- .github/workflows/sync-cnb.yml | 4 ++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.cnb/settings.yml b/.cnb/settings.yml index 46af23c5bb65e..3f24b3a409bb6 100644 --- a/.cnb/settings.yml +++ b/.cnb/settings.yml @@ -7,4 +7,26 @@ npc: 结束对话前礼貌地回复一行:"此事背后一定有一个天大的秘密。" 无论是日常对话还是讲解知识,你都会保持以上风格, 使用中文的『~』代替所有英文的『~』, - 用卑微并专业的语气回答接下来的问题 \ No newline at end of file + 用卑微并专业的语气回答接下来的问题 + + +# 云原生开发配置 +# 注意:读取云原生启动按钮所在页面当前分支的 .cnb/settings.yml 配置 +workspace: + launch: + # 定制云原生开发启动按钮 + button: + # 按钮名称 + name: 启动云原生开发 + # 按钮描述 + # 如果值为 null,则不显示默认描述 + description: 点击此按钮启动云原生开发环境 + # 鼠标悬浮图片:仓库图片填相对路径(如 .cnb/launch-hover.gif),外部图片填 raw 地址,最大 10MB + # hoverImage: .cnb/launch-hover.gif + # CPU 核心数,默认 8,仅对默认模板有效 + cpus: 16 + # 是否禁用默认按钮,默认 false + disabled: false + # 环境创建后是否自动打开 WebIDE,默认 false + # 未安装 openssh 时会自动打开,不受此参数影响 + autoOpenWebIDE: true \ No newline at end of file diff --git a/.github/workflows/sync-cnb.yml b/.github/workflows/sync-cnb.yml index 00f01b367f025..208441719b6c1 100644 --- a/.github/workflows/sync-cnb.yml +++ b/.github/workflows/sync-cnb.yml @@ -14,6 +14,10 @@ jobs: - name: Sync to CNB Repository uses: docker://tencentcom/git-sync env: + # 注入 Git LFS 配置,消除锁定提示 + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: "lfs.https://cnb.cool/soucod-github/soucod/vscode.git/info/lfs.locksverify" + GIT_CONFIG_VALUE_0: "true" PLUGIN_TARGET_URL: "https://cnb.cool/soucod-github/soucod/vscode.git" PLUGIN_AUTH_TYPE: "https" PLUGIN_USERNAME: "cnb" From a878b5f4c7ca72d35ca39d86eeab9639c572b23d Mon Sep 17 00:00:00 2001 From: LuDaShi Date: Tue, 23 Jun 2026 11:35:43 +0800 Subject: [PATCH 011/696] =?UTF-8?q?=E5=90=AF=E7=94=A8cnb=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=89=93=E5=8D=A1WebIDE=E7=9A=84=E9=85=8D=E7=BD=AE-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ide/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ide/Dockerfile b/.ide/Dockerfile index 02235ce2a492e..ea839f194508e 100644 --- a/.ide/Dockerfile +++ b/.ide/Dockerfile @@ -17,7 +17,7 @@ RUN curl -fsSL https://code-server.dev/install.sh | sh \ # 安装 ssh 服务,用于支持 VSCode 等客户端通过 Remote-SSH 访问开发环境(也可按需安装其他软件) RUN dnf update -y && dnf install -y git wget unzip openssh-server epel-release curl vim unzip procps && dnf install -y atop btop htop iftop iotop cockpit # 业务安装 -RUN dnf install -y nodejs npm python && dnf groupinstall "Development Tools" && dnf install libX11-devel.x86_64 libxkbfile-devel.x86_64 libsecret-devel krb5-devel && dnf install fakeroot rpm +RUN dnf install -y nodejs npm python && dnf groupinstall "Development Tools" -y && dnf install -y libX11-devel.x86_64 libxkbfile-devel.x86_64 libsecret-devel krb5-devel && dnf install -y fakeroot rpm RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone RUN npm i -g pnpm From 0b56f62682bba184b4f9633116acdb3734a6d286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Tue, 23 Jun 2026 09:22:42 +0200 Subject: [PATCH 012/696] Remove distro PAT (#322052) * build: derive GitHub token from Monaco GitHub App instead of PAT Replace the github-distro-mixin-password PAT (subject to 7-day rotation) with a GitHub App installation token extracted from the persisted checkout credentials. The token is republished under the same variable name to avoid churning the many GITHUB_TOKEN consumers, and the distro netrc auth now uses the x-access-token login. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: probe vscode-distro checkout via Monaco GitHub App endpoint Reverts the failed token-extraction approach (1ES persists only a credential placeholder, so the GitHub App token cannot be read from disk). Instead validate Option A: add microsoft/vscode-distro as a pipeline repository resource authenticated via the Monaco GitHub App endpoint, then check out the exact pinned SHA locally. This probe job confirms the agent can authenticate the distro checkout and that a local `git checkout ` resolves the pinned commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: check out vscode-distro via Monaco GitHub App instead of PAT Replace the PAT-based zipball download of the private microsoft/vscode-distro repository with an agent-authenticated checkout of the distro repository resource (Monaco GitHub App). The distro is checked out into .build/distro and the pinned commit from package.json is checked out locally, preserving the existing contract for mixin-npm.ts / mixin-quality.ts. Self is pinned to the default sources directory so the added distro checkout does not relocate it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: route vscode-capi and vscode-encrypt through Monaco GitHub App The github-distro-mixin-password PAT was also (via netrc) authenticating two other private repositories that are no longer reachable once the netrc is gone: - vscode-capi: cloned by common/mixin-vscode-capi.yml. Now checked out as a Monaco GitHub App repository resource and consumed from .build/vscode-capi. - vscode-encrypt: a cargo git dependency injected by the distro cli-patches. Checked out via the Monaco GitHub App and redirected with git insteadOf to the local checkout so cargo (CARGO_NET_GIT_FETCH_WITH_CLI) resolves it without a PAT. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: also route vsda cargo dependency through Monaco GitHub App The distro cli-patches inject both vscode-encrypt and vsda as private cargo git dependencies. Add vsda as a Monaco GitHub App repository resource and redirect it to a local checkout via git insteadOf, mirroring the vscode-encrypt handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: drop github-distro-mixin-password PAT Replace the broad github-distro-mixin-password PAT with: - Monaco GitHub App repo-resource checkouts for private repos (vscode-distro, vscode-capi, vscode-encrypt, vsda) - the public github-token-code-oss secret (vscode-oss-build-secrets keyvault) for generic GITHUB_TOKEN rate-limit usages Copilot now checks out vscode-capi via the Monaco App instead of a netrc clone; checkDistroCommit derives the distro branch head from the local checkout instead of the private GitHub API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: check out vscode-extensions-loc via Monaco App The copilot l10n import cloned the private microsoft/vscode-extensions-loc repository with the distro PAT. Replace it with a Monaco GitHub App repo-resource checkout (sparse) so no PAT is needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: download prebuilt Electron from Azure Artifacts feed Replaces the private GitHub release download (which required the github-distro-mixin-password PAT) with an on-demand fetch from the vscode-electron-prebuilt Azure Artifacts feed, via the new asset-resolver support in @vscode/gulp-electron 1.42.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: resolve private vsda/vscode-encrypt git deps via local checkouts The distro's npm postinstall and the CLI cargo patches both depend on the private microsoft/vsda and microsoft/vscode-encrypt repositories. Now that the distro PAT/.netrc is gone, redirect their public GitHub URLs (https and ssh) to local GitHub App checkouts via git insteadOf in download-distro.yml so every job that consumes the distro can resolve them without a PAT. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: download Alpine musl Node.js from Azure Artifacts feed The Alpine build downloaded the musl Node.js tarball from the private microsoft/vscode-node GitHub releases, which the public github-token-code-oss cannot access. Consume the new vscode-node Azure Artifacts feed via az artifacts universal download instead (authenticated with System.AccessToken), mirroring the Electron prebuilt download. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: resolve private vscode-regexp-languagedetection git dep via local checkout The distro's npm dependencies reference microsoft/vscode-regexp-languagedetection in addition to microsoft/vsda. Check it out via the Monaco GitHub App and redirect its public GitHub URL (https and ssh) to the local checkout so the distro npm postinstall resolves it without a PAT. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: download server Node.js from Azure Artifacts feed Fetch the prebuilt server (reh) Node.js binaries on demand from the vscode-node Azure Artifacts feed (gated on VSCODE_NODEJS_INTERNAL_FEED) instead of from a private GitHub release, so the build no longer needs a long-lived PAT. Extracts the shared az universal-package download helper into build/lib/azureFeed.ts, reused by the Electron resolver. Bumps the server node ms_build_id to 449655 to match the feed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: resolve distro private git deps inside the Alpine container The Alpine server build installs the distro's npm dependencies (which reference the private vsda and vscode-regexp-languagedetection git repos) inside a docker container. The host git insteadOf redirects are not visible there and use host paths, so emit a container-pathed gitconfig (.build/.gitconfig-distro) from download-distro.yml and bind-mount it as /root/.gitconfig, replacing the now-unused .netrc mount. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: use Electron/Node feeds job-wide and fix cmd.exe git redirect Promote VSCODE_ELECTRON_PREBUILT_FEED, VSCODE_NODEJS_INTERNAL_FEED and AZURE_DEVOPS_EXT_PAT to job-level variables so every step (including the integration/smoke test steps that download Electron) resolves binaries from our Azure Artifacts feeds instead of private GitHub releases. Also keep the cross-platform private git redirect step to plain 'git config' invocations so it works in cmd.exe on Windows agents, and move the bash-only container gitconfig generation into a Linux-only step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: pin server Node.js to build 438265 to match distro checksums The distro overlays build/checksums/nodejs.txt with checksums for the original 24.15.0-438265 Node.js build (including the Alpine musl binary, whose contents differ from later rebuilds). Republish the original 438265 artifacts to the vscode-node Azure Artifacts feed and pin ms_build_id back to 438265 so feed downloads match the pinned checksums. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: require ADO org/project from pipeline env vars Resolve the Azure Artifacts organization and project from the SYSTEM_COLLECTIONURI / SYSTEM_TEAMPROJECT pipeline variables (the predefined System.CollectionUri / System.TeamProject), failing fast if either is missing instead of falling back to hardcoded values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove internal feed variables for Node.js and Electron from build configurations * build: scope AZURE_DEVOPS_EXT_PAT to individual steps Move the System.AccessToken propagation out of the job-level variables in the alpine/darwin/linux/win32 product-build jobs and onto only the steps that actually download Electron or Node.js from the Azure Artifacts feeds. Adds it to the test 'Download Electron and Playwright' step (the sole feed download outside the compile templates); every other download step already declared it at step scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * :lipsitck: * update distro * build: declare vscode-capi for SDL scan in copilot recovery pipeline The Copilot build steps now check out microsoft/vscode-capi via the Monaco GitHub App (instead of a PAT). The product-copilot-recovery pipeline extends the 1ES extension template, which requires every checked-out repository to be declared under sdl.sourceRepositoriesToScan. Re-declare the template's default excludes plus capi to fix 'repository "capi" ... has not been specified'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: add assertDistroCheckout function to validate vscode-distro checkout * build: fix NPM registry corruption in copilot recovery pipeline setup-npm-registry.ts silently substituted the literal string "undefined" into package-lock.json resolved URLs when invoked without a registry URL, producing build/undefined/... paths that npm ci could not resolve. - Fail fast in setup-npm-registry.ts when no registry URL is provided. - Set NPM_REGISTRY=none in product-copilot-recovery.yml so the shared copilot/setup-steps.yml skips its registry rewrite; the 1ES extension template already configures the registry via customNPMRegistry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: fix self checkout and l10n in copilot recovery pipeline The 1ES extension template's package job runs import-localized-files before buildSteps, relying on an implicit self checkout. That implicit checkout is disabled because copilot/build-steps.yml checks out microsoft/vscode-capi, so the source tree was missing and the l10n-detection step failed with 'Not found workingDirectory: .../extensions/copilot'. Mirror product-copilot.yml: check out self at the start of buildSteps, import translations via copilot/l10n-steps.yml (with the vscode_loc resource), and disable the template's own l10n jobs/import (l10nShouldProcess: false). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: exclude vscode build-tooling natives from copilot recovery GLIBC check The 1ES extension template's GLIBC/GLIBCXX check scans every .node under the sources root, but only extensions/copilot is packaged into the VSIX. Native modules outside it (e.g. build/node_modules/tree-sitter) belong to the vscode build tooling and are never shipped, yet their newer GLIBC/GLIBCXX deps fail the check. Remove them before the check so it only validates the copilot extension's own shipped natives, alongside the existing pvrecorder removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: disable sysroot GLIBC check in copilot recovery pipeline The copilot extension is bundled with esbuild and vendors prebuilt native modules (@os-theme, @picovoice/pvrecorder-node) that depend on a newer GLIBC than the 1ES template's sysroot check allows. The main product build ships these natives without a sysroot or GLIBC check, so set useSysroot: false on the recovery pipeline's Linux platform for parity, which skips the toolchain setup and the GLIBC/GLIBCXX verification. Drop the now-unnecessary native-removal workaround. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update distro version in package.json --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/.cachesalt | 2 +- .../alpine/product-build-alpine-cli.yml | 9 +- .../product-build-alpine-node-modules.yml | 21 +- .../alpine/product-build-alpine.yml | 29 +- build/azure-pipelines/cli/cli-compile.yml | 2 +- .../common/checkDistroCommit.ts | 48 +- build/azure-pipelines/common/checkout.yml | 5 + .../common/install-builtin-extensions.yml | 2 +- .../common/mixin-vscode-capi.yml | 32 +- build/azure-pipelines/copilot/build-steps.yml | 16 +- build/azure-pipelines/copilot/l10n-steps.yml | 23 +- build/azure-pipelines/copilot/setup-steps.yml | 22 - .../darwin/product-build-darwin-cli.yml | 7 + .../product-build-darwin-node-modules.yml | 11 +- .../darwin/product-build-darwin-universal.yml | 11 +- .../steps/product-build-darwin-compile.yml | 22 +- .../steps/product-build-darwin-test.yml | 3 +- build/azure-pipelines/dependencies-check.yml | 9 +- build/azure-pipelines/distro-build.yml | 28 + .../distro/download-distro.yml | 129 ++-- .../linux/product-build-linux-cli.yml | 9 +- .../product-build-linux-node-modules.yml | 12 +- .../steps/product-build-linux-compile.yml | 27 +- .../linux/steps/product-build-linux-test.yml | 3 +- build/azure-pipelines/product-build.yml | 38 + .../product-copilot-recovery.yml | 68 +- build/azure-pipelines/product-copilot.yml | 1 + build/azure-pipelines/product-publish.yml | 8 +- .../product-quality-checks.yml | 15 +- .../web/product-build-web-node-modules.yml | 8 +- .../azure-pipelines/web/product-build-web.yml | 12 +- .../win32/product-build-win32-cli.yml | 7 + .../product-build-win32-node-modules.yml | 8 +- .../azure-pipelines/win32/sdl-scan-win32.yml | 13 +- .../steps/product-build-win32-compile.yml | 19 +- .../win32/steps/product-build-win32-test.yml | 3 +- build/gulpfile.reh.ts | 65 +- build/lib/azureFeed.ts | 71 ++ build/lib/electron.ts | 42 +- build/npm/postinstall.ts | 2 +- build/setup-npm-registry.ts | 9 +- package-lock.json | 667 ++++++++---------- package.json | 4 +- product.json | 3 +- 44 files changed, 898 insertions(+), 647 deletions(-) create mode 100644 build/lib/azureFeed.ts diff --git a/build/.cachesalt b/build/.cachesalt index df1d8c82aab64..d382d477ac6c7 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2026-06-19T12:30:00.000Z +2026-06-22T15:10:10.546Z diff --git a/build/azure-pipelines/alpine/product-build-alpine-cli.yml b/build/azure-pipelines/alpine/product-build-alpine-cli.yml index 4a8bc36e0ecf9..3081390b6a8c2 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-cli.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-cli.yml @@ -34,6 +34,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - script: | @@ -41,7 +48,7 @@ jobs: npm ci workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - task: Npm@1 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 07f1545ca2483..7b560b328677b 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml @@ -25,9 +25,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -88,11 +88,20 @@ jobs: mkdir -p .build/nodejs-musl NODE_VERSION=$(grep '^target=' remote/.npmrc | cut -d '"' -f 2) BUILD_ID=$(grep '^ms_build_id=' remote/.npmrc | cut -d '"' -f 2) - gh release download "v${NODE_VERSION}-${BUILD_ID}" -R microsoft/vscode-node -p "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" --dir .build/nodejs-musl --clobber + az extension add --name azure-devops --upgrade --only-show-errors + az artifacts universal download \ + --organization "https://dev.azure.com/monacotools" \ + --project "Monaco" \ + --scope project \ + --feed "vscode-node" \ + --name "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar" \ + --version "${NODE_VERSION}-${BUILD_ID}" \ + --path .build/nodejs-musl \ + --only-show-errors tar -xzf ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" -C ".build/nodejs-musl" --strip-components=1 rm ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + AZURE_DEVOPS_EXT_PAT: "$(System.AccessToken)" displayName: Download NodeJS MUSL condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -111,7 +120,7 @@ jobs: npm_config_arch: $(NPM_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:alpine-$(VSCODE_ARCH) VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" VSCODE_NPMRC_PATH: $(NPMRC_PATH) diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index 3359224d3ed8d..8151a3397af5a 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -67,9 +67,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -134,11 +134,20 @@ jobs: mkdir -p .build/nodejs-musl NODE_VERSION=$(grep '^target=' remote/.npmrc | cut -d '"' -f 2) BUILD_ID=$(grep '^ms_build_id=' remote/.npmrc | cut -d '"' -f 2) - gh release download "v${NODE_VERSION}-${BUILD_ID}" -R microsoft/vscode-node -p "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" --dir .build/nodejs-musl --clobber + az extension add --name azure-devops --upgrade --only-show-errors + az artifacts universal download \ + --organization "https://dev.azure.com/monacotools" \ + --project "Monaco" \ + --scope project \ + --feed "vscode-node" \ + --name "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar" \ + --version "${NODE_VERSION}-${BUILD_ID}" \ + --path .build/nodejs-musl \ + --only-show-errors tar -xzf ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" -C ".build/nodejs-musl" --strip-components=1 rm ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + AZURE_DEVOPS_EXT_PAT: "$(System.AccessToken)" displayName: Download NodeJS MUSL condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -157,7 +166,7 @@ jobs: npm_config_arch: $(NPM_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:alpine-$(VSCODE_ARCH) VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" VSCODE_NPMRC_PATH: $(NPMRC_PATH) @@ -194,7 +203,7 @@ jobs: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: npx deemon --attach -- node build/azure-pipelines/common/downloadCopilotVsix.ts @@ -214,7 +223,8 @@ jobs: echo "##vso[task.setvariable variable=SERVER_DIR_PATH]$DIR_PATH" echo "##vso[task.setvariable variable=SERVER_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - script: | @@ -229,5 +239,6 @@ jobs: echo "##vso[task.setvariable variable=WEB_DIR_PATH]$DIR_PATH" echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) diff --git a/build/azure-pipelines/cli/cli-compile.yml b/build/azure-pipelines/cli/cli-compile.yml index c3565a9bf4b23..9b92bfcd08389 100644 --- a/build/azure-pipelines/cli/cli-compile.yml +++ b/build/azure-pipelines/cli/cli-compile.yml @@ -135,7 +135,7 @@ steps: env: CARGO_NET_GIT_FETCH_WITH_CLI: true VSCODE_CLI_COMMIT: $(Build.SourceVersion) - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" RUSTC_WRAPPER: sccache SCCACHE_DIR: $(Pipeline.Workspace)/sccache ${{ each pair in parameters.VSCODE_CLI_ENV }}: diff --git a/build/azure-pipelines/common/checkDistroCommit.ts b/build/azure-pipelines/common/checkDistroCommit.ts index 4003a10df317c..f6bf4657b1472 100644 --- a/build/azure-pipelines/common/checkDistroCommit.ts +++ b/build/azure-pipelines/common/checkDistroCommit.ts @@ -5,10 +5,16 @@ import path from 'path'; import fs from 'fs'; -import { retry } from './retry.ts'; +import { execSync } from 'child_process'; const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); +// The microsoft/vscode-distro repository is checked out locally by +// download-distro.yml (into .build/distro) using the agent's GitHub App +// (Monaco) credentials, so we can resolve branch heads without a token that +// has private repository access. +const distroPath = path.join(root, '.build', 'distro'); + function getEnv(name: string): string { const result = process.env[name]; @@ -19,30 +25,14 @@ function getEnv(name: string): string { return result; } -interface GitHubBranchResponse { - commit: { - sha: string; - }; -} - -async function getDistroBranchHead(branch: string, token: string): Promise { - const url = `https://api.github.com/repos/microsoft/vscode-distro/branches/${encodeURIComponent(branch)}`; - - const response = await fetch(url, { - headers: { - 'Accept': 'application/vnd.github+json', - 'Authorization': `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'VSCode Build' - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch branch ${branch} from vscode-distro: ${response.status} ${response.statusText}`); +function assertDistroCheckout(): void { + if (!fs.existsSync(path.join(distroPath, '.git'))) { + throw new Error(`Expected a vscode-distro checkout at ${distroPath} but found none. Ensure download-distro.yml ran before this check.`); } +} - const data = await response.json() as GitHubBranchResponse; - return data.commit.sha; +function getDistroBranchHead(branch: string): string { + return execSync(`git -C "${distroPath}" rev-parse "refs/remotes/origin/${branch}"`, { encoding: 'utf8' }).trim(); } async function checkDistroCommit(): Promise { @@ -71,16 +61,18 @@ async function checkDistroCommit(): Promise { const branch = branchMatch[1]; console.log(`Current branch: ${branch}`); - // Get the GitHub token - const token = getEnv('GITHUB_TOKEN'); + // Make sure the distro repository is actually checked out before we try to + // resolve a branch head from it; otherwise a missing checkout would be + // indistinguishable from a branch that simply doesn't exist in distro. + assertDistroCheckout(); - // Fetch the HEAD of the matching branch in vscode-distro + // Resolve the HEAD of the matching branch from the local distro checkout let distroBranchHead: string; try { - distroBranchHead = await retry(() => getDistroBranchHead(branch, token)); + distroBranchHead = getDistroBranchHead(branch); } catch (error) { // If the branch doesn't exist in distro, that's expected for feature branches - console.log(`Could not fetch branch '${branch}' from vscode-distro: ${error}`); + console.log(`Could not resolve branch '${branch}' from local vscode-distro checkout: ${error}`); console.log('This is expected for feature branches that have not been merged to distro'); return; } diff --git a/build/azure-pipelines/common/checkout.yml b/build/azure-pipelines/common/checkout.yml index 427f190480a53..d1313b5e2ed6d 100644 --- a/build/azure-pipelines/common/checkout.yml +++ b/build/azure-pipelines/common/checkout.yml @@ -1,5 +1,10 @@ steps: + # Pin self to the default sources directory (`s`) so that adding a second + # checkout (e.g. the distro repository in download-distro.yml) does not relocate + # self into a repo-named subfolder, which would break every script that assumes + # self lives at $(Build.SourcesDirectory). - checkout: self + path: s fetchDepth: 1 fetchTags: false retryCountOnTaskFailure: 3 diff --git a/build/azure-pipelines/common/install-builtin-extensions.yml b/build/azure-pipelines/common/install-builtin-extensions.yml index f9cbfd4b0854a..d431bff7fc10e 100644 --- a/build/azure-pipelines/common/install-builtin-extensions.yml +++ b/build/azure-pipelines/common/install-builtin-extensions.yml @@ -19,6 +19,6 @@ steps: - script: node build/lib/builtInExtensions.ts env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" condition: and(succeeded(), ne(variables.BUILTIN_EXTENSIONS_RESTORED, 'true')) displayName: Download built-in extensions diff --git a/build/azure-pipelines/common/mixin-vscode-capi.yml b/build/azure-pipelines/common/mixin-vscode-capi.yml index 6acbd604ddd3a..4d2a42f619dc6 100644 --- a/build/azure-pipelines/common/mixin-vscode-capi.yml +++ b/build/azure-pipelines/common/mixin-vscode-capi.yml @@ -1,4 +1,13 @@ steps: + # Check out the private microsoft/vscode-capi repository using the agent's + # GitHub App (Monaco) credentials instead of cloning it with a PAT. The + # repository resource is declared in the top-level pipeline (resources.repositories). + - checkout: capi + path: s/.build/vscode-capi + fetchDepth: 1 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-capi + - pwsh: | $ErrorActionPreference = 'Stop' @@ -10,25 +19,14 @@ steps: } } - $CapiPath = Join-Path '$(Agent.BuildDirectory)' 'vscode-capi' - if (Test-Path $CapiPath) { - Remove-Item -Recurse -Force $CapiPath - } + $CapiPath = Join-Path '$(Build.SourcesDirectory)' '.build/vscode-capi' + Push-Location $CapiPath try { - Invoke-CheckedCommand { git clone https://github.com/microsoft/vscode-capi.git --depth 1 $CapiPath } - Push-Location $CapiPath - - try { - Invoke-CheckedCommand { npm ci } - $env:BUILD_SOURCESDIRECTORY = '$(Build.SourcesDirectory)' - Invoke-CheckedCommand { npm run mixin_vscode } - } finally { - Pop-Location - } + Invoke-CheckedCommand { npm ci } + $env:BUILD_SOURCESDIRECTORY = '$(Build.SourcesDirectory)' + Invoke-CheckedCommand { npm run mixin_vscode } } finally { - if (Test-Path $CapiPath) { - Remove-Item -Recurse -Force $CapiPath - } + Pop-Location } displayName: Mixin vscode-capi diff --git a/build/azure-pipelines/copilot/build-steps.yml b/build/azure-pipelines/copilot/build-steps.yml index 6a0ae05d86d11..4e4d8afd87ded 100644 --- a/build/azure-pipelines/copilot/build-steps.yml +++ b/build/azure-pipelines/copilot/build-steps.yml @@ -1,10 +1,18 @@ steps: + # Check out the private microsoft/vscode-capi repository using the agent's + # GitHub App (Monaco) credentials instead of cloning it with a PAT. The + # repository resource is declared in the top-level pipeline (resources.repositories). + - checkout: capi + path: s/.build/vscode-capi + fetchDepth: 1 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-capi + - script: | set -e - git clone https://github.com/microsoft/vscode-capi.git --depth 1 $(Agent.BuildDirectory)/vscode-capi - cd $(Agent.BuildDirectory)/vscode-capi - npm ci && BUILD_SOURCESDIRECTORY=$(Build.SourcesDirectory)/extensions/copilot npm run mixin - rm -rf $(Agent.BuildDirectory)/vscode-capi + cd $(Build.SourcesDirectory)/.build/vscode-capi + npm ci + BUILD_SOURCESDIRECTORY=$(Build.SourcesDirectory)/extensions/copilot npm run mixin displayName: Mixin vscode-capi - script: npm run build diff --git a/build/azure-pipelines/copilot/l10n-steps.yml b/build/azure-pipelines/copilot/l10n-steps.yml index 99d5b249d6fc8..a7857a69c8afe 100644 --- a/build/azure-pipelines/copilot/l10n-steps.yml +++ b/build/azure-pipelines/copilot/l10n-steps.yml @@ -1,21 +1,23 @@ steps: + # Check out the private microsoft/vscode-extensions-loc repository using the + # agent's GitHub App (Monaco) credentials instead of cloning it with a PAT. + # The repository resource is declared in the top-level pipeline (resources.repositories). + - checkout: vscode_loc + path: s/.build/vscode-extensions-loc + fetchDepth: 1 + sparseCheckoutDirectories: out/GitHub.copilot-chat + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-extensions-loc + - script: | set -e EXTENSION_ID="GitHub.copilot-chat" - L10N_REPO="https://github.com/microsoft/vscode-extensions-loc.git" - L10N_DIR="$(Agent.TempDirectory)/vscode-extensions-loc" - - echo "Cloning vscode-extensions-loc (sparse checkout)..." - git clone --depth 1 --filter=blob:none --sparse "$L10N_REPO" "$L10N_DIR" - cd "$L10N_DIR" - git sparse-checkout set "out/$EXTENSION_ID" - + L10N_DIR="$(Build.SourcesDirectory)/.build/vscode-extensions-loc" TRANSLATED_DIR="$L10N_DIR/out/$EXTENSION_ID" if [ ! -d "$TRANSLATED_DIR" ] || [ -z "$(ls -A "$TRANSLATED_DIR" 2>/dev/null)" ]; then echo "No translated strings found for $EXTENSION_ID, skipping l10n import." - rm -rf "$L10N_DIR" exit 0 fi @@ -36,7 +38,4 @@ steps: echo "Localized files:" ls -la package.nls.*.json 2>/dev/null || echo " (no package.nls.*.json)" ls -la "$L10N_ROOT"/bundle.l10n.*.json 2>/dev/null || echo " (no bundle.l10n.*.json)" - - # Cleanup - rm -rf "$L10N_DIR" displayName: Import localized strings diff --git a/build/azure-pipelines/copilot/setup-steps.yml b/build/azure-pipelines/copilot/setup-steps.yml index 83699f8152d63..e550d6ac69605 100644 --- a/build/azure-pipelines/copilot/setup-steps.yml +++ b/build/azure-pipelines/copilot/setup-steps.yml @@ -3,28 +3,6 @@ steps: inputs: versionSpec: "22.21.x" - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get Secrets" - inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" - - - pwsh: | - "machine github.com`nlogin vscode`npassword $(github-distro-mixin-password)" | Out-File "$Home/_netrc" -Encoding ASCII - condition: and(succeeded(), contains(variables['Agent.OS'], 'windows')) - displayName: Setup distro auth (Windows) - - - script: | - mkdir -p .build - cat << EOF | tee ~/.netrc .build/.netrc > /dev/null - machine github.com - login vscode - password $(github-distro-mixin-password) - EOF - condition: and(succeeded(), not(contains(variables['Agent.OS'], 'windows'))) - displayName: Setup distro auth (non-Windows) - - pwsh: node build/setup-npm-registry.ts $env:NPM_REGISTRY workingDirectory: $(Build.SourcesDirectory) condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none'), contains(variables['Agent.OS'], 'windows')) diff --git a/build/azure-pipelines/darwin/product-build-darwin-cli.yml b/build/azure-pipelines/darwin/product-build-darwin-cli.yml index 5a705b43b8ef2..1d68fe9161348 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-cli.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-cli.yml @@ -38,6 +38,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 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 d70adbee0af01..e811c7397a72c 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml @@ -28,7 +28,14 @@ jobs: inputs: azureSubscription: vscode KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password,macos-developer-certificate,macos-developer-certificate-key" + SecretsFilter: "macos-developer-certificate,macos-developer-certificate-key" + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -78,7 +85,7 @@ jobs: npm_config_arch: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" # Avoid using dlopen to load Kerberos on macOS which can cause missing libraries # https://github.com/mongodb-js/kerberos/commit/04044d2814ad1d01e77f1ce87f26b03d86692cf2 # flipped the default to support legacy linux distros which shouldn't happen diff --git a/build/azure-pipelines/darwin/product-build-darwin-universal.yml b/build/azure-pipelines/darwin/product-build-darwin-universal.yml index c5e4dfb588a84..455adf5e2cf58 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-universal.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-universal.yml @@ -36,7 +36,14 @@ jobs: inputs: azureSubscription: vscode KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password,macos-developer-certificate,macos-developer-certificate-key" + SecretsFilter: "macos-developer-certificate,macos-developer-certificate-key" + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY build condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -71,7 +78,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - pwsh: node -- build/azure-pipelines/common/waitForArtifacts.ts unsigned_vscode_client_darwin_x64_archive unsigned_vscode_client_darwin_arm64_archive diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index b7315e170ee62..41d4a30a133fd 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -28,7 +28,14 @@ steps: inputs: azureSubscription: vscode KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password,macos-developer-certificate,macos-developer-certificate-key" + SecretsFilter: "macos-developer-certificate,macos-developer-certificate-key" + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -82,7 +89,7 @@ steps: npm_config_arch: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" # Avoid using dlopen to load Kerberos on macOS which can cause missing libraries # https://github.com/mongodb-js/kerberos/commit/04044d2814ad1d01e77f1ce87f26b03d86692cf2 # flipped the default to support legacy linux distros which shouldn't happen @@ -127,7 +134,7 @@ steps: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: node build/azure-pipelines/common/extract-telemetry.ts @@ -156,7 +163,8 @@ steps: npm run gulp vscode-darwin-$(VSCODE_ARCH)-min-ci echo "##vso[task.setvariable variable=BUILT_CLIENT]true" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build client - script: | @@ -164,7 +172,8 @@ steps: npm run gulp vscode-reh-darwin-$(VSCODE_ARCH)-min-ci mv ../vscode-reh-darwin-$(VSCODE_ARCH) ../vscode-server-darwin-$(VSCODE_ARCH) # TODO@joaomoreno env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - script: | @@ -172,7 +181,8 @@ steps: npm run gulp vscode-reh-web-darwin-$(VSCODE_ARCH)-min-ci mv ../vscode-reh-web-darwin-$(VSCODE_ARCH) ../vscode-server-darwin-$(VSCODE_ARCH)-web # TODO@joaomoreno env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml index 2cb75de8dddd6..e790f126a7729 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml @@ -9,7 +9,8 @@ parameters: steps: - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 diff --git a/build/azure-pipelines/dependencies-check.yml b/build/azure-pipelines/dependencies-check.yml index d7d0d9f12b6dd..78b74bc342523 100644 --- a/build/azure-pipelines/dependencies-check.yml +++ b/build/azure-pipelines/dependencies-check.yml @@ -27,6 +27,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - script: | set -e echo "Checking if package.json or package-lock.json files were modified..." @@ -102,7 +109,7 @@ jobs: npm_command: 'install --ignore-scripts' ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies with retries timeoutInMinutes: 1300 condition: and(succeeded(), eq(variables['SHOULD_VALIDATE'], 'true')) diff --git a/build/azure-pipelines/distro-build.yml b/build/azure-pipelines/distro-build.yml index 1d0a50b129778..05ac00cec7db8 100644 --- a/build/azure-pipelines/distro-build.yml +++ b/build/azure-pipelines/distro-build.yml @@ -7,7 +7,35 @@ trigger: include: ["main", "release/*"] pr: none +resources: + repositories: + - repository: distro + type: github + name: microsoft/vscode-distro + ref: main + endpoint: Monaco + - repository: encrypt + type: github + name: microsoft/vscode-encrypt + ref: main + endpoint: Monaco + - repository: vsda + type: github + name: microsoft/vsda + ref: main + endpoint: Monaco + - repository: regexp + type: github + name: microsoft/vscode-regexp-languagedetection + ref: main + endpoint: Monaco + steps: + - checkout: self + path: s + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode + - task: NodeTool@0 inputs: versionSource: fromFile diff --git a/build/azure-pipelines/distro/download-distro.yml b/build/azure-pipelines/distro/download-distro.yml index b7c52dd36b41c..7d6319ee0be73 100644 --- a/build/azure-pipelines/distro/download-distro.yml +++ b/build/azure-pipelines/distro/download-distro.yml @@ -1,61 +1,90 @@ steps: - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get Secrets" - inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" - - # TODO@joaomoreno: Keep pwsh once we move out of running entire jobs in containers - - pwsh: | - "machine github.com`nlogin vscode`npassword $(github-distro-mixin-password)" | Out-File "$Home/_netrc" -Encoding ASCII - condition: and(succeeded(), contains(variables['Agent.OS'], 'windows')) - displayName: Setup distro auth (Windows) + # Check out the private microsoft/vscode-distro repository using the agent's + # GitHub App (Monaco) credentials. This avoids the need for a long-lived PAT. + # The repository resource is declared in the top-level pipeline (resources.repositories) + # and is checked out into .build/distro so that mixin-npm.ts / mixin-quality.ts can + # consume it from there (`.build/distro/npm`, `.build/distro/mixin/`). + - checkout: distro + path: s/.build/distro + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-distro - pwsh: | $ErrorActionPreference = "Stop" - $ArchivePath = "$(Agent.TempDirectory)/distro.zip" - $PackageJson = Get-Content -Path package.json -Raw | ConvertFrom-Json - $DistroVersion = $PackageJson.distro - - Invoke-WebRequest -Uri "https://api.github.com/repos/microsoft/vscode-distro/zipball/$DistroVersion" ` - -OutFile $ArchivePath ` - -Headers @{ "Accept" = "application/vnd.github+json"; "Authorization" = "Bearer $(github-distro-mixin-password)"; "X-GitHub-Api-Version" = "2022-11-28" } + $DistroVersion = (Get-Content -Path package.json -Raw | ConvertFrom-Json).distro + Write-Host "Checking out distro commit $DistroVersion" + git -C .build/distro checkout $DistroVersion + displayName: Checkout distro commit - New-Item -ItemType Directory -Path .build -Force - Expand-Archive -Path $ArchivePath -DestinationPath .build - Rename-Item -Path ".build/microsoft-vscode-distro-$DistroVersion" -NewName distro - condition: and(succeeded(), contains(variables['Agent.OS'], 'windows')) - displayName: Download distro (Windows) + - script: git lfs install --local + displayName: Initialize Git LFS - - script: | - mkdir -p .build - cat << EOF | tee ~/.netrc .build/.netrc > /dev/null - machine github.com - login vscode - password $(github-distro-mixin-password) - EOF - condition: and(succeeded(), not(contains(variables['Agent.OS'], 'windows'))) - displayName: Setup distro auth (non-Windows) + - script: git lfs pull + displayName: Pull Git LFS objects - - script: | - set -e - ArchivePath="$(Agent.TempDirectory)/distro.zip" - DistroVersion=$(node -p "require('./package.json').distro") + # Check out the private microsoft/vscode-encrypt, microsoft/vsda and + # microsoft/vscode-regexp-languagedetection repositories using the agent's GitHub + # App (Monaco) credentials. The distro's npm dependencies reference vsda and + # vscode-regexp-languagedetection as git dependencies, and the CLI cargo patches + # reference vsda and vscode-encrypt; npm resolves these over ssh while cargo + # resolves them over https. We redirect both public GitHub URLs to these local + # checkouts via git's insteadOf so no PAT is required to resolve them. + - checkout: encrypt + path: s/.build/vscode-encrypt + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-encrypt - curl -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $(github-distro-mixin-password)" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - -o $ArchivePath \ - -L "https://api.github.com/repos/microsoft/vscode-distro/zipball/$DistroVersion" + - checkout: vsda + path: s/.build/vsda + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vsda - unzip $ArchivePath -d .build - mv .build/microsoft-vscode-distro-$DistroVersion .build/distro - condition: and(succeeded(), not(contains(variables['Agent.OS'], 'windows'))) - displayName: Download distro (non-Windows) + - checkout: regexp + path: s/.build/vscode-regexp-languagedetection + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-regexp-languagedetection - - script: git lfs install --local - displayName: Initialize Git LFS + # Redirect the private git dependencies to the local checkouts above, so no PAT + # is required to resolve them. npm resolves these over ssh while cargo resolves + # them over https, with and without a trailing `.git`, so we register every + # form. This step runs on the agent directly and must work in both bash and + # cmd.exe (Windows), so it uses plain `git config` invocations. + - script: | + git config --global url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "https://github.com/microsoft/vscode-encrypt" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "https://github.com/microsoft/vscode-encrypt.git" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "ssh://git@github.com/microsoft/vscode-encrypt" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "ssh://git@github.com/microsoft/vscode-encrypt.git" + git config --global url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "https://github.com/microsoft/vsda" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "https://github.com/microsoft/vsda.git" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "ssh://git@github.com/microsoft/vsda" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "ssh://git@github.com/microsoft/vsda.git" + git config --global url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "https://github.com/microsoft/vscode-regexp-languagedetection" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "https://github.com/microsoft/vscode-regexp-languagedetection.git" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "ssh://git@github.com/microsoft/vscode-regexp-languagedetection" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "ssh://git@github.com/microsoft/vscode-regexp-languagedetection.git" + displayName: Redirect private git dependencies to local checkouts - - script: git lfs pull - displayName: Pull Git LFS objects + # The Alpine server build installs the distro's npm dependencies inside a docker + # container that mounts the sources at /root/vscode, so the host redirects above + # (which use agent paths) do not apply there. Emit a container-pathed gitconfig + # that postinstall.ts bind-mounts as /root/.gitconfig. Only needed (and only + # bash-compatible) on Linux agents. + - script: | + set -e + CONTAINER_GITCONFIG="$(Build.SourcesDirectory)/.build/.gitconfig-distro" + rm -f "$CONTAINER_GITCONFIG" + for repo in vscode-encrypt vsda vscode-regexp-languagedetection; do + for url in \ + "https://github.com/microsoft/$repo" \ + "https://github.com/microsoft/$repo.git" \ + "ssh://git@github.com/microsoft/$repo" \ + "ssh://git@github.com/microsoft/$repo.git"; do + git config --file "$CONTAINER_GITCONFIG" --add url."file:///root/vscode/.build/$repo".insteadOf "$url" + done + done + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + displayName: Write container git redirects for Alpine diff --git a/build/azure-pipelines/linux/product-build-linux-cli.yml b/build/azure-pipelines/linux/product-build-linux-cli.yml index a9107129b73b5..c84a1fd08e273 100644 --- a/build/azure-pipelines/linux/product-build-linux-cli.yml +++ b/build/azure-pipelines/linux/product-build-linux-cli.yml @@ -34,6 +34,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 @@ -84,7 +91,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - script: | 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 ad0e149816014..9be0c3f1daf40 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -27,9 +27,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: | set -e @@ -93,7 +93,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -109,7 +109,7 @@ jobs: SYSROOT_ARCH="$SYSROOT_ARCH" VSCODE_SYSROOT_PREFIX="-glibc-2.28-gcc-8.5.0" node -e 'import { getVSCodeSysroot } from "./build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' env: VSCODE_ARCH: $(VSCODE_ARCH) - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Download vscode sysroots condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -135,7 +135,7 @@ jobs: VSCODE_ARCH: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index 4443e0cd869cc..2f1df9d23c914 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -34,9 +34,9 @@ steps: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: | set -e @@ -104,7 +104,7 @@ steps: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -120,7 +120,7 @@ steps: SYSROOT_ARCH="$SYSROOT_ARCH" VSCODE_SYSROOT_PREFIX="-glibc-2.28-gcc-8.5.0" node -e 'import { getVSCodeSysroot } from "./build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' env: VSCODE_ARCH: $(VSCODE_ARCH) - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Download vscode sysroots - script: | @@ -145,7 +145,7 @@ steps: VSCODE_ARCH: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -186,7 +186,7 @@ steps: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: @@ -219,7 +219,8 @@ steps: echo "##vso[task.setvariable variable=CLIENT_PATH]$ARCHIVE_PATH" echo "##vso[task.setvariable variable=CLIENT_ARCHIVE_NAME]$(basename $ARCHIVE_PATH)" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build client - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: @@ -247,7 +248,7 @@ steps: set -e tar -czf $CLIENT_PATH -C .. VSCode-linux-$(VSCODE_ARCH) env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Archive client - ${{ if ne(parameters.VSCODE_ARCH, 'armhf') }}: @@ -262,7 +263,8 @@ steps: echo "##vso[task.setvariable variable=SERVER_PATH]$ARCHIVE_PATH" echo "##vso[task.setvariable variable=SERVER_UNARCHIVE_PATH]$UNARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - script: | @@ -274,7 +276,8 @@ steps: tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-server-linux-$(VSCODE_ARCH)-web echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) - ${{ if or(eq(parameters.VSCODE_ARCH, 'x64'), eq(parameters.VSCODE_ARCH, 'arm64')) }}: @@ -296,7 +299,7 @@ steps: set -e npm run gulp "vscode-linux-$(VSCODE_ARCH)-prepare-deb" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Prepare deb package - script: | diff --git a/build/azure-pipelines/linux/steps/product-build-linux-test.yml b/build/azure-pipelines/linux/steps/product-build-linux-test.yml index 5c6223cf371d8..2dfd2a85d5adb 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-test.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-test.yml @@ -9,7 +9,8 @@ parameters: steps: - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index db99e5013997d..ec9a21700e464 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -180,11 +180,49 @@ resources: type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release + - repository: distro + type: github + name: microsoft/vscode-distro + endpoint: Monaco + ref: main + - repository: capi + type: github + name: microsoft/vscode-capi + endpoint: Monaco + ref: main + - repository: encrypt + type: github + name: microsoft/vscode-encrypt + endpoint: Monaco + ref: main + - repository: vsda + type: github + name: microsoft/vsda + endpoint: Monaco + ref: main + - repository: regexp + type: github + name: microsoft/vscode-regexp-languagedetection + endpoint: Monaco + ref: main + - repository: vscode_loc + type: github + name: microsoft/vscode-extensions-loc + endpoint: Monaco + ref: main extends: template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines parameters: sdl: + sourceRepositoriesToScan: + exclude: + - repository: distro + - repository: capi + - repository: encrypt + - repository: vsda + - repository: regexp + - repository: vscode_loc tsa: enabled: true configFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/tsaoptions.json diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index a0c52e6830247..6f7888e934c21 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -10,6 +10,16 @@ resources: name: microsoft/vscode-engineering ref: main endpoint: Monaco + - repository: capi + type: github + name: microsoft/vscode-capi + ref: main + endpoint: Monaco + - repository: vscode_loc + type: github + name: microsoft/vscode-extensions-loc + ref: main + endpoint: Monaco parameters: - name: customNPMRegistry @@ -24,6 +34,12 @@ parameters: variables: - name: VSCODE_QUALITY value: stable + # The 1ES extension template configures the NPM registry itself (via the + # customNPMRegistry / Terrapin parameter below). Set NPM_REGISTRY to 'none' + # so the shared copilot/setup-steps.yml skips its own registry rewrite, which + # would otherwise corrupt package-lock.json resolved URLs when unset. + - name: NPM_REGISTRY + value: none extends: template: azure-pipelines/extension/stable.yml@templates @@ -31,6 +47,24 @@ extends: workingDirectory: ./extensions/copilot l10nSourcePaths: ./extensions/copilot/src nodeVersion: 22.21.x + # The copilot extension is bundled with esbuild and vendors prebuilt native + # modules (e.g. @os-theme, @picovoice/pvrecorder-node) that depend on a + # newer GLIBC than the template's sysroot check allows. The main product + # build (product-copilot.yml) ships these natives without a sysroot or GLIBC + # check, so disable the sysroot here for parity; this also skips the GLIBC/ + # GLIBCXX verification that would otherwise fail on those vendored natives. + buildPlatforms: + - name: Linux + vsceTarget: "" + useSysroot: false + codesignPaths: [] + # Copilot handles localization itself via copilot/l10n-steps.yml (importing + # translated strings from the microsoft/vscode-extensions-loc checkout), + # mirroring product-copilot.yml. Disable the template's own l10n jobs and + # the import-localized-files step: the latter runs before buildSteps and + # expects an implicit self checkout, which is unavailable here because + # build-steps.yml checks out microsoft/vscode-capi. + l10nShouldProcess: false cgIgnoreDirectories: $(Build.SourcesDirectory)/extensions/copilot/script @@ -46,21 +80,17 @@ extends: exit 1 displayName: Validate release branch + - checkout: self + path: s + lfs: true + fetchDepth: 1 + fetchTags: false + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode + - template: copilot/setup-steps.yml - template: copilot/build-steps.yml - - script: | - set -e - - # The GLIBC check (from the shared extension template) scans every .node - # under $PWD. @github/copilot vendors @picovoice/pvrecorder-node, whose - # linux/x86_64 prebuild depends on GLIBC > 2.28. The native is not - # shipped in the VSIX, so just remove the pvrecorder prebuilds before - # the check runs. - find "$(Build.SourcesDirectory)" \ - -type d -name pvrecorder-node \ - -path "*@picovoice/pvrecorder-node" \ - -print -exec rm -rf {} + - displayName: Remove pvrecorder native binaries before GLIBC check + - template: copilot/l10n-steps.yml uploadSourceMaps: enabled: true @@ -78,6 +108,18 @@ extends: serviceTreeID: '1788a767-5861-45fb-973b-c686b67c5541' enabled: true + # The Copilot build steps check out microsoft/vscode-capi and + # microsoft/vscode-extensions-loc (via the Monaco GitHub App instead of a + # PAT). Every checked-out repository must be declared for the 1ES SDL source + # analysis, so re-declare the template defaults plus capi and vscode_loc here. + sourceRepositoriesToScan: + exclude: + - repository: translations + - repository: vscode-copilot-cache + - repository: templates + - repository: capi + - repository: vscode_loc + ${{ if eq(parameters.customNPMRegistry, false) }}: customNPMRegistry: '' diff --git a/build/azure-pipelines/product-copilot.yml b/build/azure-pipelines/product-copilot.yml index cebe647882fac..cb3210e41cc61 100644 --- a/build/azure-pipelines/product-copilot.yml +++ b/build/azure-pipelines/product-copilot.yml @@ -17,6 +17,7 @@ jobs: value: true steps: - checkout: self + path: s lfs: true fetchDepth: 1 fetchTags: false diff --git a/build/azure-pipelines/product-publish.yml b/build/azure-pipelines/product-publish.yml index 0a0ada4ec3d41..6403a6303a159 100644 --- a/build/azure-pipelines/product-publish.yml +++ b/build/azure-pipelines/product-publish.yml @@ -39,9 +39,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get ESRP Secrets" @@ -58,7 +58,7 @@ jobs: npm ci workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - download: current diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml index 792c70ebb3890..b7919b52e47ff 100644 --- a/build/azure-pipelines/product-quality-checks.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -20,9 +20,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -68,7 +68,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -78,7 +78,7 @@ jobs: SYSROOT_ARCH="amd64" VSCODE_SYSROOT_PREFIX="-glibc-2.28-gcc-8.5.0" node -e 'import { getVSCodeSysroot } from "./build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' env: VSCODE_ARCH: x64 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Download vscode sysroots - script: | @@ -100,7 +100,7 @@ jobs: VSCODE_ARCH: x64 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -122,7 +122,6 @@ jobs: - script: node build/azure-pipelines/common/checkDistroCommit.ts displayName: Check distro commit env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" BUILD_SOURCEBRANCH: "$(Build.SourceBranch)" continueOnError: true condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) @@ -131,7 +130,7 @@ jobs: - script: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile & Hygiene - script: npm run download-builtin-extensions-cg 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 75a0cc6cd6e75..fdd15d54f1a10 100644 --- a/build/azure-pipelines/web/product-build-web-node-modules.yml +++ b/build/azure-pipelines/web/product-build-web-node-modules.yml @@ -18,9 +18,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -73,7 +73,7 @@ jobs: env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index 86d6215f31cf0..7666105b4d147 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -30,9 +30,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -89,7 +89,7 @@ jobs: env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -119,7 +119,7 @@ jobs: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: npx deemon --attach -- node build/azure-pipelines/common/downloadCopilotVsix.ts @@ -135,7 +135,7 @@ jobs: tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-web echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Build - task: AzureCLI@2 diff --git a/build/azure-pipelines/win32/product-build-win32-cli.yml b/build/azure-pipelines/win32/product-build-win32-cli.yml index c01e421e6d443..fd56ba730a085 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli.yml @@ -35,6 +35,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 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 6780073f57af7..9167e5b6a004b 100644 --- a/build/azure-pipelines/win32/product-build-win32-node-modules.yml +++ b/build/azure-pipelines/win32/product-build-win32-node-modules.yml @@ -29,9 +29,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -76,7 +76,7 @@ jobs: npm_config_foreground_scripts: "true" ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" retryCountOnTaskFailure: 5 displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index 1d41892bf8d31..65c3423d64800 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -22,9 +22,9 @@ steps: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -88,7 +88,7 @@ steps: npm_config_foreground_scripts: "true" ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" retryCountOnTaskFailure: 5 displayName: Install dependencies @@ -106,12 +106,13 @@ steps: - powershell: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - powershell: npm run gulp "vscode-symbols-win32-${{ parameters.VSCODE_ARCH }}" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Symbols - powershell: | diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index 2539ad4f9180c..aec5863690539 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -33,9 +33,9 @@ steps: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -84,7 +84,7 @@ steps: npm_config_foreground_scripts: "true" ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" retryCountOnTaskFailure: 5 displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -126,7 +126,7 @@ steps: - powershell: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: node build/azure-pipelines/common/extract-telemetry.ts @@ -168,7 +168,8 @@ steps: echo "##vso[task.setvariable variable=BUILT_CLIENT]true" echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH)" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build client # Note: the appx prepare step has to follow Build client step since build step replaces the template @@ -199,7 +200,8 @@ steps: echo "##vso[task.setvariable variable=BUILT_SERVER]true" echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(CodeSigningFolderPath),$(Agent.BuildDirectory)/vscode-server-win32-$(VSCODE_ARCH)" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - powershell: | @@ -213,7 +215,8 @@ steps: echo "##vso[task.setvariable variable=BUILT_WEB]true" echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(CodeSigningFolderPath),$(Agent.BuildDirectory)/vscode-server-win32-$(VSCODE_ARCH)-web" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: diff --git a/build/azure-pipelines/win32/steps/product-build-win32-test.yml b/build/azure-pipelines/win32/steps/product-build-win32-test.yml index 187856b227229..128ff569ca03e 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-test.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-test.yml @@ -11,7 +11,8 @@ parameters: steps: - powershell: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index 62c30da5216af..b00894781aee6 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -26,9 +26,11 @@ import { compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; import { vscodeWebResourceIncludes, createVSCodeWebFileContentMapper } from './gulpfile.vscode.web.ts'; import * as cp from 'child_process'; +import crypto from 'crypto'; import log from 'fancy-log'; import buildfile from './buildfile.ts'; -import { fetchUrls, fetchGithub } from './lib/fetch.ts'; +import { fetchUrls } from './lib/fetch.ts'; +import { downloadFeedPackage } from './lib/azureFeed.ts'; import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; @@ -208,6 +210,44 @@ function patchElfLoadAlign(): NodeJS.ReadWriteStream { const { nodeVersion, internalNodeVersion } = getNodeVersion(); +// In product builds, the server (reh) Node.js binaries are fetched on demand +// from our Azure Artifacts feed named by `product.nodejsArtifactFeed` using the +// `az` CLI, instead of from nodejs.org (which is used by OSS builds when no feed +// is configured). Each universal package contains exactly one file, named after +// the asset minus its last extension, lowercased and sanitized (e.g. +// `node-v24.15.0-linux-x64.tar`, `win-x64-node`). +const nodejsArtifactFeed = product.nodejsArtifactFeed; + +function internalNodeFeedPackageName(assetName: string): string { + return assetName + .replace(/\.[^.]+$/, '') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^[._-]+/, '') + .replace(/[._-]+$/, ''); +} + +function fetchNodejsFromInternalFeed(feed: string, assetName: string, version: string, checksumSha256: string | undefined): NodeJS.ReadWriteStream { + const result = es.through(); + (async () => { + try { + const filePath = await downloadFeedPackage(REPO_ROOT, 'nodejs-feed', { feed, name: internalNodeFeedPackageName(assetName), version }); + const contents = await fs.promises.readFile(filePath); + if (checksumSha256) { + const actual = crypto.createHash('sha256').update(contents).digest('hex'); + if (actual !== checksumSha256) { + throw new Error(`Checksum mismatch for ${assetName} (expected ${checksumSha256}, actual ${actual})`); + } + } + result.emit('data', new File({ path: path.basename(filePath), contents })); + result.emit('end'); + } catch (err) { + result.emit('error', err); + } + })(); + return result; +} + BUILD_TARGETS.forEach(({ platform, arch }) => { task.task(task.define(`node-${platform}-${arch}`, () => { const nodePath = path.join('.build', 'node', `v${nodeVersion}`, `${platform}-${arch}`); @@ -237,13 +277,13 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi arch = 'x64'; } - log(`Downloading node.js ${nodeVersion} ${platform} ${arch} from ${product.nodejsRepository}...`); + log(`Downloading node.js ${nodeVersion} ${platform} ${arch} from ${nodejsArtifactFeed || 'https://nodejs.org'}...`); const glibcPrefix = process.env['VSCODE_NODE_GLIBC'] ?? ''; let expectedName: string | undefined; switch (platform) { case 'win32': - expectedName = product.nodejsRepository !== 'https://nodejs.org' ? + expectedName = nodejsArtifactFeed ? `win-${arch}-node.exe` : `win-${arch}/node.exe`; break; @@ -267,14 +307,14 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi switch (platform) { case 'win32': - return (product.nodejsRepository !== 'https://nodejs.org' ? - fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) : + return (nodejsArtifactFeed ? + fetchNodejs(expectedName!, checksumSha256) : fetchUrls(`/dist/v${nodeVersion}/win-${arch}/node.exe`, { base: 'https://nodejs.org', checksumSha256 })) .pipe(rename('node.exe')); case 'darwin': case 'linux': { - const downloaded = (product.nodejsRepository !== 'https://nodejs.org' ? - fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) : + const downloaded = (nodejsArtifactFeed ? + fetchNodejs(expectedName!, checksumSha256) : fetchUrls(`/dist/v${nodeVersion}/node-v${nodeVersion}-${platform}-${arch}.tar.gz`, { base: 'https://nodejs.org', checksumSha256 }) ).pipe(flatmap(stream => stream.pipe(gunzip()).pipe(untar()))) .pipe(filter('**/node')) @@ -283,8 +323,8 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi return platform === 'linux' && arch === 'x64' ? downloaded.pipe(patchElfLoadAlign()) : downloaded; } case 'alpine': - return product.nodejsRepository !== 'https://nodejs.org' ? - fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) + return nodejsArtifactFeed ? + fetchNodejs(expectedName!, checksumSha256) .pipe(flatmap(stream => stream.pipe(gunzip()).pipe(untar()))) .pipe(filter('**/node')) .pipe(util.setExecutableBit('**')) @@ -293,6 +333,13 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi } } +// Fetches a server (reh) Node.js asset from the Azure Artifacts feed named by +// `product.nodejsArtifactFeed`. Only called when that feed is configured. +function fetchNodejs(assetName: string, checksumSha256: string | undefined): NodeJS.ReadWriteStream { + const version = `${nodeVersion}-${internalNodeVersion}`; + return fetchNodejsFromInternalFeed(nodejsArtifactFeed, assetName, version, checksumSha256); +} + function packageTask(type: string, platform: string, arch: string, sourceFolderName: string, destinationFolderName: string) { const destination = path.join(BUILD_ROOT, destinationFolderName); diff --git a/build/lib/azureFeed.ts b/build/lib/azureFeed.ts new file mode 100644 index 0000000000000..f32fb21885daa --- /dev/null +++ b/build/lib/azureFeed.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import cp from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +function getEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function azExecFile(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = cp.spawn('az', args, { stdio: 'inherit', shell: process.platform === 'win32' }); + child.on('error', reject); + child.on('close', code => code === 0 ? resolve() : reject(new Error(`az ${args[0]} ${args[1] ?? ''} exited with code ${code}`))); + }); +} + +let azureDevOpsExtension: Promise | undefined; +function ensureAzureDevOpsExtension(): Promise { + if (!azureDevOpsExtension) { + azureDevOpsExtension = (async () => { + const result = cp.spawnSync('az', ['extension', 'show', '--name', 'azure-devops'], { stdio: 'ignore', shell: process.platform === 'win32' }); + if (result.status !== 0) { + await azExecFile(['extension', 'add', '--name', 'azure-devops', '--only-show-errors']); + } + })(); + } + return azureDevOpsExtension; +} + +export interface IFeedPackage { + readonly feed: string; + readonly name: string; + readonly version: string; +} + +/** + * Downloads a universal package from an Azure Artifacts feed into a cache + * directory under `/.build/` and returns the absolute path to + * the single file it contains. Subsequent requests for the same package are + * served from the cache. + * @param root the repository root + * @param cacheDir the `.build` sub directory used to cache downloaded packages + * @param pkg the feed, package name and version to download + */ +export async function downloadFeedPackage(root: string, cacheDir: string, pkg: IFeedPackage): Promise { + const dir = path.join(root, '.build', cacheDir, `${pkg.name}-${pkg.version}`); + if (!fs.existsSync(dir)) { + await ensureAzureDevOpsExtension(); + await azExecFile([ + 'artifacts', 'universal', 'download', + '--organization', getEnv('SYSTEM_COLLECTIONURI').replace(/\/+$/, ''), + '--project', getEnv('SYSTEM_TEAMPROJECT'), + '--scope', 'project', + '--feed', pkg.feed, + '--name', pkg.name, + '--version', pkg.version, + '--path', dir, + ]); + } + const [only] = await fs.promises.readdir(dir); + return path.join(dir, only); +} diff --git a/build/lib/electron.ts b/build/lib/electron.ts index 016c25f7553db..e229b875f552c 100644 --- a/build/lib/electron.ts +++ b/build/lib/electron.ts @@ -5,10 +5,12 @@ import fs from 'fs'; import path from 'path'; +import { Readable } from 'stream'; import vfs from 'vinyl-fs'; import { filter, jsonEditor } from './gulp/facade.ts'; import * as util from './util.ts'; import { getVersion } from './getVersion.ts'; +import { downloadFeedPackage } from './azureFeed.ts'; import electron from '@vscode/gulp-electron'; type DarwinDocumentSuffix = 'document' | 'script' | 'file' | 'source code'; @@ -101,11 +103,45 @@ function darwinBundleDocumentTypes(types: { [name: string]: string | string[] }, } const { msBuildId } = util.getElectronVersion(); -const electronVersion = '42.2.0'; +export const electronVersion = '42.2.0'; + +// In product builds, `@vscode/gulp-electron` is given an asset resolver (via the +// `repo` option) that fetches the prebuilt Electron archives on demand from the +// Azure Artifacts feed named by `product.electronArtifactFeed` using the `az` +// CLI, instead of downloading them from electron's official GitHub releases +// (which OSS builds use when no feed is configured). Each universal package +// contains exactly one file, which is streamed back as a `Response` and +// validated against the feed's `SHASUMS256.txt`. +const electronFeed: string | undefined = product.electronArtifactFeed; + +// Maps the artifact file name `@vscode/gulp-electron` requests to the matching +// universal package name in the feed, or `undefined` when it is not mirrored. +function feedPackageName(fileName: string): string | undefined { + if (fileName === 'SHASUMS256.txt') { + return 'shasums256'; + } + if (fileName.endsWith('-symbols.zip')) { + return undefined; + } + return fileName.replace(/\.zip$/, ''); +} + +const electronAssetResolver = electronFeed + ? async ({ fileName }: { url: string; fileName: string }): Promise => { + const name = feedPackageName(fileName); + if (!name) { + return new Response(null, { status: 404 }); + } + const version = `${electronVersion}-${msBuildId}`; + const filePath = await downloadFeedPackage(root, 'electron-feed', { feed: electronFeed, name, version }); + const size = (await fs.promises.stat(filePath)).size; + const body = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream; + return new Response(body, { status: 200, headers: { 'Content-Length': String(size) } }); + } + : undefined; export const config = { version: electronVersion, - tag: product.electronRepository ? `v${electronVersion}-${msBuildId}` : undefined, productAppName: product.nameLong, companyName: 'Microsoft Corporation', copyright: 'Copyright (C) 2026 Microsoft. All rights reserved', @@ -203,7 +239,7 @@ export const config = { linuxExecutableName: product.applicationName, winIcon: 'resources/win32/code.ico', token: process.env['GITHUB_TOKEN'], - repo: product.electronRepository || undefined, + repo: electronAssetResolver, validateChecksum: true, checksumFile: path.join(root, 'build', 'checksums', 'electron.txt'), createVersionedResources: useVersionedUpdate, diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index 0d00ac3926141..5df04a796373f 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -79,7 +79,7 @@ async function npmInstallAsync(dir: string, opts?: child_process.SpawnOptions): 'docker', 'run', '-e', 'GITHUB_TOKEN', '-v', `${process.env['VSCODE_HOST_MOUNT']}:/root/vscode`, - '-v', `${process.env['VSCODE_HOST_MOUNT']}/.build/.netrc:/root/.netrc`, + '-v', `${process.env['VSCODE_HOST_MOUNT']}/.build/.gitconfig-distro:/root/.gitconfig`, '-v', `${process.env['VSCODE_NPMRC_PATH']}:/root/.npmrc`, '-w', path.resolve('/root/vscode', dir), process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME'], diff --git a/build/setup-npm-registry.ts b/build/setup-npm-registry.ts index 670c3e339db0f..4560f2c000728 100644 --- a/build/setup-npm-registry.ts +++ b/build/setup-npm-registry.ts @@ -37,6 +37,10 @@ async function setup(url: string, file: string): Promise { * Main function to set up custom NPM registry */ async function main(url: string, dir?: string): Promise { + if (!url) { + throw new Error('Usage: node setup-npm-registry.ts [dir]. A registry URL is required.'); + } + const root = dir ?? process.cwd(); for await (const file of getPackageLockFiles(root)) { @@ -45,4 +49,7 @@ async function main(url: string, dir?: string): Promise { } } -main(process.argv[2], process.argv[3]); +main(process.argv[2], process.argv[3]).catch(err => { + console.error(err.message); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index 5a343cdc62b70..450186b2b5819 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,7 +108,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260609", "@vscode/component-explorer": "^0.2.1-58", "@vscode/component-explorer-cli": "^0.2.1-59", - "@vscode/gulp-electron": "1.41.3", + "@vscode/gulp-electron": "^1.42.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", @@ -1146,9 +1146,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1165,9 +1162,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1184,9 +1178,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1203,9 +1194,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -2827,6 +2815,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-8.1.0.tgz", + "integrity": "sha512-2SX/1jW6CIMAiebvVv5ZInoCEuWQmMyBoJXXGC6Vjakjp/fpxP5eHs7/V6WKuPEIbuK06+VpjH+vjLQhr98rDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -3024,9 +3025,9 @@ } }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "dev": true, "license": "MIT" }, @@ -3750,15 +3751,16 @@ "license": "MIT" }, "node_modules/@vscode/gulp-electron": { - "version": "1.41.3", - "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.41.3.tgz", - "integrity": "sha512-M+f3LqnZKyIf3k5fxAeKHtz5/0V9PALJqneVh7vDZ32wdbokhFmfVqzP8Z+alBjZViuL80cLe65znjELnsxUBw==", + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.42.0.tgz", + "integrity": "sha512-DQLhu7p3GnGAc1tvYtArqp3duHD+b7ddpWitqYckdioFJGAszUSf7o6KZ5szo6sjFBz+E8rvpu9EMYOjdAAMzg==", "dev": true, "license": "MIT", "dependencies": { - "@electron/get": "^4.0.1", + "@electron/get": "^5.0.0", "@octokit/rest": "^22.0.0", "event-stream": "3.3.4", + "got": "^15.0.5", "gulp-filter": "^5.1.0", "gulp-rename": "1.2.2", "gulp-symdest": "^1.2.0", @@ -3778,203 +3780,6 @@ "node": ">=22" } }, - "node_modules/@vscode/gulp-electron/node_modules/@electron/get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-4.0.1.tgz", - "integrity": "sha512-fTMFb/ZiK6xQace5YZlhT+vNR08ogat9SqpvwpaC9vD6hgx7ouz9cdcrSrFuNji4823Jmmy90/CDhJq0I4vRFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^3.0.0", - "got": "^14.4.5", - "graceful-fs": "^4.2.11", - "progress": "^2.0.3", - "semver": "^7.6.3", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/@electron/get/node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/cacheable-request": { - "version": "13.0.18", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.18.tgz", - "integrity": "sha512-rFWadDRKJs3s2eYdXlGggnBZKG7MTblkFBB0YllFds+UYnfogDp2wcR6JN97FhRkHTvq59n2vhNoHNZn29dh/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.4", - "get-stream": "^9.0.1", - "http-cache-semantics": "^4.2.0", - "keyv": "^5.5.5", - "mimic-response": "^4.0.0", - "normalize-url": "^8.1.1", - "responselike": "^4.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/decompress-response": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", - "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^4.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/got": { - "version": "14.6.6", - "resolved": "https://registry.npmjs.org/got/-/got-14.6.6.tgz", - "integrity": "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^7.0.1", - "byte-counter": "^0.1.0", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^13.0.12", - "decompress-response": "^10.0.0", - "form-data-encoder": "^4.0.2", - "http2-wrapper": "^2.2.1", - "keyv": "^5.5.3", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^4.0.1", - "responselike": "^4.0.2", - "type-fest": "^4.26.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/http2-wrapper": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", - "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@vscode/gulp-electron/node_modules/mkdirp": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", @@ -3987,29 +3792,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/@vscode/gulp-electron/node_modules/normalize-url": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", - "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/p-cancelable": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", - "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, "node_modules/@vscode/gulp-electron/node_modules/rcedit": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-4.0.1.tgz", @@ -4023,22 +3805,6 @@ "node": ">= 14.0.0" } }, - "node_modules/@vscode/gulp-electron/node_modules/responselike": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", - "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@vscode/iconv-lite-umd": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.1.tgz", @@ -5595,13 +5361,6 @@ "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24= sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, - "node_modules/boolean": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.0.2.tgz", - "integrity": "sha512-RwywHlpCRc3/Wh81MiCKun4ydaIFyW5Ea6JbL6sRCVx5q5irDw7pMXBUFYF/jArQ6YrG36q0kpovc9P/Kd3I4g==", - "dev": true, - "optional": true - }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -5821,6 +5580,75 @@ "node": ">=0.10.0" } }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "13.0.19", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.19.tgz", + "integrity": "sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.2.0", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.2.0", + "keyv": "^5.6.0", + "mimic-response": "^4.0.0", + "normalize-url": "^8.1.1", + "responselike": "^4.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -6029,6 +5857,19 @@ "integrity": "sha1-BKEGZywYsIWrd02YPfo+oTjyIgU= sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", "dev": true }, + "node_modules/chunk-data": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chunk-data/-/chunk-data-0.1.0.tgz", + "integrity": "sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ci-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", @@ -7075,13 +6916,6 @@ "node": ">=0.10.0" } }, - "node_modules/detect-node": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.0.4.tgz", - "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==", - "dev": true, - "optional": true - }, "node_modules/devtools-protocol": { "version": "0.0.1173815", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1173815.tgz", @@ -7568,13 +7402,6 @@ "node": ">=0.10" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "optional": true - }, "node_modules/es6-iterator": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", @@ -9063,16 +8890,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", - "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -9769,24 +9586,6 @@ "node": ">=0.10.0" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, "node_modules/global-modules": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", @@ -9883,6 +9682,59 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/got/-/got-15.0.5.tgz", + "integrity": "sha512-PMIMaZuYUCK43+Z9JWEXea4kkX2b3301m81D5TS6QpfG4PmNyirzEdO/Oa2OHAN4GsjnPfvWCWsshKN2rq4/gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^8.0.0", + "byte-counter": "^0.1.0", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^13.0.18", + "chunk-data": "^0.1.0", + "decompress-response": "^10.0.0", + "http2-wrapper": "^2.2.1", + "keyv": "^5.6.0", + "lowercase-keys": "^4.0.1", + "responselike": "^4.0.2", + "type-fest": "^5.6.0", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/decompress-response": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", + "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -11459,6 +11311,20 @@ "node": ">= 14" } }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -12741,14 +12607,6 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -13274,6 +13132,19 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-4.0.1.tgz", + "integrity": "sha512-wI9Nui/L8VfADa/cr/7NQruaASk1k23/Uh1khQ02BCVYiiy8F4AhOGnQzJy3Fl/c44GnYSbZHv8g7EcG3kJ1Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", @@ -13567,19 +13438,6 @@ "node": ">=0.10.0" } }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13750,6 +13608,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -14389,6 +14260,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/now-and-later": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", @@ -15808,6 +15692,7 @@ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -16318,7 +16203,8 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/resolve-dir": { "version": "1.0.1", @@ -16420,6 +16306,35 @@ "deprecated": "https://github.com/lydell/resolve-url#deprecated", "dev": true }, + "node_modules/responselike": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", + "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/responselike/node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -16495,24 +16410,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -16690,13 +16587,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w= sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "optional": true - }, "node_modules/semver-greatest-satisfied-range": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz", @@ -16773,35 +16663,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/serialize-javascript": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", @@ -17515,13 +17376,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "optional": true - }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -18175,6 +18029,19 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -18723,13 +18590,16 @@ } }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "dev": true, "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -18925,6 +18795,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index afdde577903d6..4ab0081fbd2a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.127.0", - "distro": "e6b302180cc8fecde16d482d0f2add78b067b6bf", + "distro": "0d726028b9bf2f38975a035474182955e771bccd", "author": { "name": "Microsoft Corporation" }, @@ -192,7 +192,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260609", "@vscode/component-explorer": "^0.2.1-58", "@vscode/component-explorer-cli": "^0.2.1-59", - "@vscode/gulp-electron": "1.41.3", + "@vscode/gulp-electron": "^1.42.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", diff --git a/product.json b/product.json index 06ab8fcda8e8b..9b7a7947ee303 100644 --- a/product.json +++ b/product.json @@ -31,7 +31,8 @@ "linuxIconName": "code-oss", "licenseFileName": "LICENSE.txt", "reportIssueUrl": "https://github.com/microsoft/vscode/issues/new", - "nodejsRepository": "https://nodejs.org", + "nodejsArtifactFeed": "", + "electronArtifactFeed": "", "urlProtocol": "code-oss", "agentsTelemetryAppName": "agents", "webviewContentExternalBaseUrlTemplate": "https://{{uuid}}.vscode-cdn.net/insider/ef65ac1ba57f57f2a3961bfe94aa20481caca4c6/out/vs/workbench/contrib/webview/browser/pre/", From d5a69d4d6318fbddf3d6c85af0072fe6e8795ffb Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Tue, 23 Jun 2026 12:27:12 +0500 Subject: [PATCH 013/696] Validate predicted line for cross-file cursor jumps in NES (#322437) Validate predicted line for cross-file cursor jumps The cross-file cursor-jump path in `XtabProvider.handleCrossFilePrediction` fed the model's predicted line number straight into a synthetic request without checking it against the target document's line count. When the model predicted an out-of-bounds line, `CurrentDocument.lineWithCursor()` threw a `BugIndicatingError` ("cursor is out of bounds") that propagated up through `getNextEdit` and surfaced as an unhandled error in telemetry. The same-file path already guards against this (`exceedsDocumentLines`); this mirrors that guard for the cross-file path, returning `NoSuggestions` and recording `crossFile:exceedsDocumentLines` instead of throwing. Fixes #318579 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/extension/xtab/node/xtabProvider.ts | 10 +++- .../xtab/test/node/xtabProvider.spec.ts | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index 4be3a966049fc..d8e718be98ad1 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -1282,11 +1282,19 @@ export class XtabProvider implements IStatelessNextEditProvider { } const targetContent = new StringText(targetTextDoc.getText()); + const targetContentLines = targetContent.getLines(); + + if (prediction.lineNumber >= targetContentLines.length) { // >= because the line index is zero-based + tracer.trace(`Predicted cross-file cursor jump error: exceedsDocumentLines`); + telemetry.setNextCursorLineError('crossFile:exceedsDocumentLines'); + return new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, editWindow); + } + const syntheticDoc = new StatelessNextEditDocument( targetDocumentId, promptPieces.activeDoc.workspaceRoot, LanguageId.create(targetTextDoc.languageId), - targetContent.getLines(), + targetContentLines, LineEdit.empty, targetContent, new Edits(StringEdit, []), diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index 00253ca1e0a49..47dd5c519fefa 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -22,8 +22,10 @@ import { ILogger } from '../../../../platform/log/common/logService'; import { FilterReason } from '../../../../platform/networking/common/openai'; import { ISimulationTestContext } from '../../../../platform/simulationTestContext/common/simulationTestContext'; import { TestLogService } from '../../../../platform/testing/common/testLogService'; +import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { AsyncIterUtils } from '../../../../util/common/asyncIterableUtils'; import { Result } from '../../../../util/common/result'; +import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument'; import { DeferredPromise } from '../../../../util/vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; @@ -1770,6 +1772,59 @@ describe('XtabProvider integration', () => { expect(streamingFetcher.callCount).toBe(3); }); + it('cross-file cursor jump with out-of-bounds predicted line → NoSuggestions without throwing', async () => { + const provider = createProvider(); + await configService.setConfig(ConfigKey.InlineEditsNextCursorPredictionEnabled, true); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsNextCursorPredictionModelName, 'test-model'); + + const lines = Array.from({ length: 30 }, (_, i) => `line ${i} content`); + const cursorOffset = lines.slice(0, 5).join('\n').length; + const request = createRequestWithEdit(lines, { insertionOffset: cursorOffset }); + + // 1st call (main LLM): edit-window lines unchanged → no edits → cursor jump path + const mainEditWindowLines = lines.slice(3, 11); + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-main', + serverRequestId: 'srv-main', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: mainEditWindowLines.join('\n'), + resolvedModel: 'test-model', + }); + + // 2nd call (cursor prediction): predict a jump into another file at line 50 (0-based), + // which is out of bounds for the 5-line target document opened below. + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-cursor', + serverRequestId: 'srv-cursor', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: '/test/other.ts:50', + resolvedModel: 'test-model', + }); + + // The cross-file jump target only has 5 lines, so the predicted line 50 is out of bounds. + const targetDoc = createTextDocumentData( + URI.file('/test/other.ts'), + Array.from({ length: 5 }, (_, i) => `other ${i}`).join('\n'), + 'typescript', + ).document; + const workspaceService = instaService.invokeFunction(accessor => accessor.get(IWorkspaceService)); + const openSpy = vi.spyOn(workspaceService, 'openTextDocument').mockResolvedValue(targetDoc); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + // The out-of-bounds prediction must be rejected before any retry fetch happens, rather + // than constructing a CurrentDocument with an out-of-bounds cursor (which would throw). + expect(streamingFetcher.callCount).toBe(2); + expect(openSpy).toHaveBeenCalledOnce(); + + openSpy.mockRestore(); + }); + it('model fallback retry on NotFound then yields edits on second attempt', async () => { const provider = createProvider(); From e0c8ac1b825687f5c2ac4ba8a33cbfffbc2ac760 Mon Sep 17 00:00:00 2001 From: yulia-vasyura Date: Tue, 23 Jun 2026 00:30:07 -0700 Subject: [PATCH 014/696] Renamed "Apply Update..." command to "Apply Update from File...". (#322504) --- .../workbench/contrib/update/browser/update.contribution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index 777207dae808a..618b985891c3d 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -217,7 +217,7 @@ if (isWindows) { constructor() { super({ id: '_update.applyupdate', - title: localize2('applyUpdate', 'Apply Update...'), + title: localize2('applyUpdate', 'Apply Update from File...'), category: Categories.Developer, f1: true, precondition: CONTEXT_UPDATE_STATE.isEqualTo(StateType.Idle) @@ -229,7 +229,7 @@ if (isWindows) { const fileDialogService = accessor.get(IFileDialogService); const updatePath = await fileDialogService.showOpenDialog({ - title: localize('pickUpdate', "Apply Update"), + title: localize('pickUpdate', "Apply Update from File"), filters: [{ name: 'Setup', extensions: ['exe'] }], canSelectFiles: true, openLabel: mnemonicButtonLabel(localize({ key: 'updateButton', comment: ['&& denotes a mnemonic'] }, "&&Update")) From be4a0366988d7574f69c9618ea1202af0f55c89e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 23 Jun 2026 09:49:13 +0200 Subject: [PATCH 015/696] sessions: make Sessions Part the high-priority layout view (#322513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agents window grid uses proportionalLayout: false, so on window resize (or part show/hide) the extra width is absorbed by the highest-LayoutPriority view in the horizontal chain. None of the views were High: the sidebar and auxiliary bar are Low and the Sessions Part and editor were Normal. Because a grid branch derives its priority from its children, the Low auxiliary bar pulled the whole right section down to Low, leaving Sidebar (Low) | Right Section (Low) with no high-priority absorber — so the resize delta spread across both and the sidebar grew toward half the window. A previous change moved the flexible role off the editor (High -> Normal) to stop the editor drifting to its minimum when toggling the auxiliary bar, and updated LAYOUT.md to say the Sessions Part is the High view, but never actually set it. This completes that change by setting the Sessions Part to LayoutPriority.High so the right section absorbs horizontal deltas and the sidebar keeps its fixed width. Also documents the layout priority model and its invariant in LAYOUT.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 15 +++++++++++++++ src/vs/sessions/browser/parts/sessionsPart.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 30c0519a126a6..15bf0deeabce6 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -60,6 +60,21 @@ The titlebar spans the full window width at the root level. Below it, a content The **Sessions Part is the flexible ("remaining width") view** in the top-right row: it has `LayoutPriority.High` so it absorbs auxiliary bar / editor visibility changes and window resizes. The editor and auxiliary bar keep their user-set widths (`LayoutPriority.Normal` / `Low`). Making the editor the high-priority view caused its width to drift to its 300px minimum when the auxiliary bar was toggled across session switches. +### 2.3 Layout Priority Model + +The workbench grid is built with `proportionalLayout: false` (see `createWorkbenchLayout()` in [browser/workbench.ts](src/vs/sessions/browser/workbench.ts)). In this mode the split views do **not** distribute resize deltas proportionally — instead each delta (window resize, or a part being shown/hidden) is absorbed by the highest-`LayoutPriority` view, while the others keep their established sizes. Each part therefore declares an explicit `priority`: + +| Part | `LayoutPriority` | Width behaviour | +|------|------------------|-----------------| +| Sidebar | `Low` | Fixed user-set width; never absorbs deltas. `minimumWidth` 170 (270 web), `maximumWidth` ∞, snaps closed below the minimum. | +| Sessions Part | **`High`** | The single flexible view — grows/shrinks to absorb every horizontal delta. `minimumWidth` 300, `maximumWidth` ∞. | +| Editor | `Normal` | Keeps its user-set width (`600` default); only resized via its own sash. | +| Auxiliary Bar | `Low` | Keeps its user-set width (`340` default); only resized via its own sash. | + +**Invariant — exactly one `High` view in the horizontal chain.** A grid branch derives its priority from its children (`BranchNode.priority` in [base/browser/ui/grid/gridview.ts](src/vs/base/browser/ui/grid/gridview.ts)): `High` if any child is `High`, else `Low` if any child is `Low`, else `Normal`. The Top Right row contains a `Low` auxiliary bar, so unless the Sessions Part is `High` the whole Right Section derives to `Low`. The Content Section would then be `Sidebar (Low) | Right Section (Low)` — two equal-priority views — and with no high-priority absorber the resize delta spreads across **both**, growing the sidebar toward half the window. The Sessions Part being `High` is what lifts the Right Section to `High` so it (not the sidebar) absorbs the delta. + +> **Pitfall:** the `High` role must live on the Sessions Part, not the editor. It was previously on the editor, but that made the editor drift to its 300px minimum when the auxiliary bar was toggled across session switches. When moving the role, set the Sessions Part to `High` **and** the editor to `Normal` together — removing `High` from the editor without adding it to the Sessions Part leaves the chain with no `High` view and reintroduces the growing-sidebar bug. + --- ## 3. Titlebar diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index 6caf52980f13c..8e22012b701f5 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -102,7 +102,7 @@ export class SessionsPart extends Part { return this.layoutService.mainContainerDimension.height * 0.4; } - readonly priority = LayoutPriority.Normal; + readonly priority = LayoutPriority.High; constructor( @IThemeService themeService: IThemeService, From b949969d71c3788856872a52e3bc6441292046f9 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 23 Jun 2026 17:54:24 +1000 Subject: [PATCH 016/696] agentHost: cancel pending model-refresh retry on shutdown (#322498) Agent Host changes for agents/fix-copilot-agent-auth-retry-issue-1f6470c5 --- .../agentHost/node/copilot/copilotAgent.ts | 63 ++++++++- .../agentHost/test/node/copilotAgent.test.ts | 120 ++++++++++++++++++ 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 46e50a4697b6a..a186cc08ccf68 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -6,7 +6,7 @@ import { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; import * as os from 'os'; -import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; +import { CancelablePromise, createCancelablePromise, Delayer, disposableTimeout, Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { type CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -276,6 +276,20 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _models = observableValue(this, []); readonly models = this._models; + /** + * Bounded exponential-backoff retry for {@link _refreshModels}. The SDK's + * `models.list` RPC can fail transiently (e.g. a `429 "too many requests"` + * right after startup). Without a retry the model picker would stay empty + * until the GitHub token next changes — the only other trigger for a + * refresh — so we retry a few times before giving up. Overridable in tests + * to avoid real delays. + */ + protected readonly _modelRefreshMaxAttempts: number = 5; + protected readonly _modelRefreshBaseDelayMs: number = 1_000; + protected readonly _modelRefreshMaxDelayMs: number = 30_000; + /** Pending model-refresh retry timer; cleared on a fresh refresh, shutdown, or dispose. */ + private readonly _modelRefreshRetry = this._register(new MutableDisposable()); + private _client: CopilotClient | undefined; private _clientStarting: Promise | undefined; private _githubToken: string | undefined; @@ -499,7 +513,17 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _refreshModels(): Promise { + private async _refreshModels(attempt = 0): Promise { + // A fresh refresh (e.g. a token change) supersedes any scheduled retry. + this._modelRefreshRetry.clear(); + + // Once teardown has begun, skip the refresh entirely: a retry timer that + // fires during the shutdown window would otherwise call `_ensureClient()` + // and resurrect the SDK subprocess after `shutdown()` tore it down. + if (this._shutdownPromise) { + return; + } + const tokenAtRefreshStart = this._githubToken; if (!tokenAtRefreshStart) { this._models.set([], undefined); @@ -511,13 +535,43 @@ export class CopilotAgent extends Disposable implements IAgent { this._models.set(models, undefined); } } catch (err) { + // Token rotated mid-flight — a newer refresh owns the result — or + // teardown began while the request was in flight, in which case a + // retry would just resurrect the client we are tearing down. + if (this._githubToken !== tokenAtRefreshStart || this._shutdownPromise) { + return; + } + if (attempt + 1 < this._modelRefreshMaxAttempts) { + const delay = this._modelRefreshBackoff(attempt); + this._logService.warn(`[Copilot] Failed to refresh models (attempt ${attempt + 1}), retrying in ${delay}ms`, err); + this._modelRefreshRetry.value = disposableTimeout(() => { + void this._refreshModels(attempt + 1); + }, delay); + return; + } + // Retries exhausted: surface the error. Only blank the list when we + // have nothing to show, so a transient failure never wipes a + // previously loaded, good model list. this._logService.error(err, '[Copilot] Failed to refresh models'); - if (this._githubToken === tokenAtRefreshStart) { + if (this._models.get().length === 0) { this._models.set([], undefined); } } } + /** + * Equal-jitter exponential backoff for model-refresh retries. Doubles the + * base delay per attempt (capped at {@link _modelRefreshMaxDelayMs}) and + * picks a random point in the upper half of that window, so the returned + * delay lands in `[exp/2, exp]`. The jitter avoids synchronized retries + * across windows/agents hitting a shared rate limit, while the `exp/2` + * floor keeps a minimum spacing between attempts. + */ + private _modelRefreshBackoff(attempt: number): number { + const exp = Math.min(this._modelRefreshMaxDelayMs, this._modelRefreshBaseDelayMs * 2 ** attempt); + return Math.round(exp / 2 + Math.random() * (exp / 2)); + } + private async _stopClient(): Promise { const client = this._client; this._client = undefined; @@ -1867,6 +1921,9 @@ export class CopilotAgent extends Disposable implements IAgent { async shutdown(): Promise { this._shutdownPromise ??= (async () => { + // Cancel any pending model-refresh retry so its timer cannot fire + // after teardown and resurrect the client. + this._modelRefreshRetry.clear(); this._logService.info('[Copilot] Shutting down...'); const sessionIds = new Set([...this._sessions.keys(), ...this._createdWorktrees.keys()]); for (const sessionId of sessionIds) { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1802e1acbb595..108989120593c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -355,6 +355,10 @@ class TestableCopilotAgent extends CopilotAgent { private readonly _fakeSessions = new Map(); readonly resumeCalls: string[] = []; + // Keep model-refresh retries effectively instant in tests. + protected override readonly _modelRefreshBaseDelayMs = 1; + protected override readonly _modelRefreshMaxDelayMs = 2; + constructor( private readonly _copilotClient: ITestCopilotClient, @ILogService logService: ILogService, @@ -739,6 +743,122 @@ suite('CopilotAgent', () => { } }); + test('retries refreshing models after a transient failure', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + client.modelListErrors.push(new Error('429 "too many requests"')); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const models = await waitForState(agent.models, m => m.length > 0); + + assert.deepStrictEqual({ + modelNames: models.map(m => m.name), + requestCount: client.modelListRequests.length, + }, { + modelNames: ['GPT-4o'], + requestCount: 2, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('stops refreshing models after the maximum number of attempts', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + for (let i = 0; i < 10; i++) { + client.modelListErrors.push(new Error('429 "too many requests"')); + } + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + for (let i = 0; i < 500 && client.modelListRequests.length < 5; i++) { + await new Promise(resolve => setTimeout(resolve, 1)); + } + // Give any erroneous extra retry a chance to fire before asserting. + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.deepStrictEqual({ + requestCount: client.modelListRequests.length, + models: agent.models.get(), + }, { + requestCount: 5, + models: [], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('keeps the previously loaded models when a later refresh fails', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token-a'); + await waitForState(agent.models, m => m.length > 0); + + // A refresh triggered by the next token change fails on every attempt. + for (let i = 0; i < 10; i++) { + client.modelListErrors.push(new Error('429 "too many requests"')); + } + const requestsBefore = client.modelListRequests.length; + await agent.authenticate('https://api.github.com', 'token-b'); + for (let i = 0; i < 500 && client.modelListRequests.length < requestsBefore + 5; i++) { + await new Promise(resolve => setTimeout(resolve, 1)); + } + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.deepStrictEqual({ + modelNames: agent.models.get().map(m => m.name), + retriedRequests: client.modelListRequests.length - requestsBefore, + }, { + modelNames: ['GPT-4o'], + retriedRequests: 5, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('does not refresh models or restart the client after shutdown', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, m => m.length > 0); + await agent.shutdown(); + + const startsAfterShutdown = client.startCallCount; + const requestsAfterShutdown = client.modelListRequests.length; + + // Simulate a queued model-refresh retry timer firing after shutdown. + // It must bail out rather than call `_ensureClient()` and spawn a + // fresh SDK client for an agent that is already torn down. + await (agent as unknown as { _refreshModels(attempt?: number): Promise })._refreshModels(1); + + assert.deepStrictEqual({ + starts: client.startCallCount, + requests: client.modelListRequests.length, + }, { + starts: startsAfterShutdown, + requests: requestsAfterShutdown, + }); + } finally { + await disposeAgent(agent); + } + }); + test('createSession falls back to an empty temp directory when workingDirectory is omitted', async () => { const agent = createTestAgent(disposables); let createdWorkingDirectory: URI | undefined; From 9ad71eb015b74bbc3fdc5675259af03782408556 Mon Sep 17 00:00:00 2001 From: Dmitry Guketlev Date: Tue, 23 Jun 2026 10:16:28 +0200 Subject: [PATCH 017/696] Fix `handleEndOfLifetime` `supersededBy` tracking for inline completions (#320143) --- src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts | 2 +- src/vs/workbench/api/common/extHost.protocol.ts | 1 + src/vs/workbench/api/common/extHostLanguageFeatures.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 5aca91c68098a..520abbfadb6b2 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -1415,7 +1415,7 @@ class ExtensionBackedInlineCompletionsProvider extends Disposable implements lan } if (this._supportsHandleEvents) { - await this._proxy.$handleInlineCompletionEndOfLifetime(this.handle, completions.pid, item.idx, mapReason(reason, i => ({ pid: completions.pid, idx: i.idx }))); + await this._proxy.$handleInlineCompletionEndOfLifetime(this.handle, completions.pid, item.idx, mapReason(reason, i => ({ pid: i.pid, idx: i.idx }))); } if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Accepted) { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c0dbfe28ffabc..964bd1c86ef54 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -491,6 +491,7 @@ export interface IdentifiableInlineCompletions extends languages.InlineCompletio } export interface IdentifiableInlineCompletion extends languages.InlineCompletion { + pid: number; idx: number; suggestionId: EditSuggestionId | undefined; } diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 0067d6267c999..4d5cf05bbc592 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -1478,6 +1478,7 @@ class InlineCompletionAdapter { showRange: (this._isAdditionsProposedApiEnabled && item.showRange) ? typeConvert.Range.from(item.showRange) : undefined, command, gutterMenuLinkAction: action, + pid: pid, idx: idx, completeBracketPairs: this._isAdditionsProposedApiEnabled ? item.completeBracketPairs : false, isInlineEdit: this._isAdditionsProposedApiEnabled ? item.isInlineEdit : false, From 682d038520fbe86fa4c77e7fd784b633d92a9231 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Tue, 23 Jun 2026 13:35:02 +0500 Subject: [PATCH 018/696] Add config to split xtab diff patches via per-patch diff (#322438) * nes: update defaults for current file budgeting to have budget of 1500 tokens and reuse leftover budget for code above * nes: pin useLeftoverBudgetFromAbove=false in even-split test The "context above and below get same # of tokens" test inherits its options from DEFAULT_OPTIONS.currentFile. Flipping the default useLeftoverBudgetFromAbove to true made it exercise the donation path, breaking its premise and inline snapshot. Pin the option to false so the test keeps validating the even-split behavior its name describes; the donation behavior is already covered by the dedicated expandRangeToPageRange tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * nes: file rename * Add config to split xtab diff patches via per-patch diff Add InlineEditsXtabSplitPatchOnDiff (boolean, experiment-based, default off). When enabled, each patch in xtabPatchResponseHandler is line-diffed (removed vs added lines) using the synchronous DefaultLinesDiffComputer and split into the minimal set of LineReplacements, leaving re-emitted context lines untouched for a nicer suggestion shape. Falls back to the single replacement on timeout or a single-hunk diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../xtab/node/xtabPatchResponseHandler.ts | 77 +++++++++++-- .../src/extension/xtab/node/xtabProvider.ts | 2 + ...ec.ts => xtabPatchResponseHandler.spec.ts} | 102 ++++++++++++++++++ .../common/configurationService.ts | 1 + 4 files changed, 176 insertions(+), 6 deletions(-) rename extensions/copilot/src/extension/xtab/test/node/{xtabCustomDiffPatchResponseHandler.spec.ts => xtabPatchResponseHandler.spec.ts} (91%) diff --git a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts index 804ad0809266e..63120ddec1b6f 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts @@ -13,6 +13,7 @@ import { equals as arraysEqual } from '../../../util/vs/base/common/arrays'; import { isAbsolute } from '../../../util/vs/base/common/path'; import { URI } from '../../../util/vs/base/common/uri'; import { LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit'; +import { DefaultLinesDiffComputer } from '../../../util/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer'; import { LineRange } from '../../../util/vs/editor/common/core/ranges/lineRange'; import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; import { AbstractText } from '../../../util/vs/editor/common/core/text/abstractText'; @@ -281,6 +282,64 @@ function applyDuplicatePolicy( export namespace XtabPatchResponseHandler { + /** + * Upper bound on how long the per-patch diff may run before we give up and + * fall back to the original (unsplit) replacement. The inputs are a single + * patch's removed/added lines, so this is only a safety valve against + * pathological inputs. + */ + const SPLIT_DIFF_MAX_COMPUTATION_TIME_MS = 100; + + /** + * Splits a coarse patch replacement into the minimal set of sub-replacements + * by running a line diff between the removed and added lines. + * + * The diff-patch model emits a patch as a contiguous block of `-`/`+` lines, + * which `resolveEdit` turns into a single `LineReplacement` spanning every + * removed line. When only a subset of those lines actually changed (the model + * re-emitted surrounding context), a line-level diff recovers the minimal + * hunks, yielding several small replacements with the untouched lines left as + * context — a nicer suggestion shape. + * + * @param replacement The resolved (possibly dedup-trimmed) replacement; its + * `newLines` are the added lines and its `lineRange` anchors the result in + * the document. + * @param removedLines The original line content removed by the patch. Its + * length is expected to match `replacement.lineRange.length`. + * + * Returns the original replacement unchanged when there is nothing to gain: + * a pure insertion or deletion (one side empty), a diff that times out, or a + * diff that collapses to a single hunk. + */ + export function splitReplacement(replacement: LineReplacement, removedLines: readonly string[]): LineReplacement[] { + const addedLines = replacement.newLines; + if (removedLines.length === 0 || addedLines.length === 0) { + return [replacement]; + } + + const diff = new DefaultLinesDiffComputer().computeDiff([...removedLines], [...addedLines], { + ignoreTrimWhitespace: false, + maxComputationTimeMs: SPLIT_DIFF_MAX_COMPUTATION_TIME_MS, + computeMoves: false, + }); + if (diff.hitTimeout || diff.changes.length <= 1) { + return [replacement]; + } + + // `change.original`/`change.modified` are 1-based line ranges over + // `removedLines`/`addedLines`. Anchor the original side at the + // replacement's first removed line (`removedLines[0]` lives on + // `lineRange.startLineNumber`) and slice the new content from `addedLines`. + const baseLine = replacement.lineRange.startLineNumber; + return diff.changes.map(change => new LineReplacement( + new LineRange( + baseLine + change.original.startLineNumber - 1, + baseLine + change.original.endLineNumberExclusive - 1, + ), + addedLines.slice(change.modified.startLineNumber - 1, change.modified.endLineNumberExclusive - 1), + )); + } + export async function* handleResponse( linesStream: AsyncIterable, currentDocument: CurrentDocument, @@ -290,6 +349,7 @@ export namespace XtabPatchResponseHandler { parentTracer: ILogger, duplicateAdditionsMode: DuplicateAdditionsMode = DuplicateAdditionsMode.Off, enableProgressiveGhostText: boolean = false, + splitPatchOnDiff: boolean = false, ): AsyncGenerator { const tracer = parentTracer.createSubLogger(['XtabCustomDiffPatchResponseHandler', 'handleResponse']); const activeDocRelativePath = toUniquePath(activeDocumentId, workspaceRoot?.path); @@ -330,12 +390,17 @@ export namespace XtabPatchResponseHandler { } } - yield { - edit: lineReplacement, - isFromCursorJump: false, - targetDocument, - window, - } satisfies StreamedEdit; + const replacements = splitPatchOnDiff + ? splitReplacement(lineReplacement, edit.removedLines) + : [lineReplacement]; + for (const replacement of replacements) { + yield { + edit: replacement, + isFromCursorJump: false, + targetDocument, + window, + } satisfies StreamedEdit; + } } } catch (e: unknown) { if (e instanceof FetchStreamError) { diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index d8e718be98ad1..959e2a31a937b 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -944,6 +944,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const pseudoEditWindow = currentDocument.transformer.getOffsetRange(new Range(clippedTaggedCurrentDoc.keptRange.start + 1, 1, clippedTaggedCurrentDoc.keptRange.endExclusive, lastLineLength + 1)); const duplicateAdditionsMode = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabDuplicateAdditionsMode, this.expService); const fastYieldLineWithCursor = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderPatchFastYieldLineWithCursor, this.expService); + const splitPatchOnDiff = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabSplitPatchOnDiff, this.expService); parseResult = new ResponseParseResult.DirectEdits( XtabPatchResponseHandler.handleResponse( linesStream, @@ -954,6 +955,7 @@ export class XtabProvider implements IStatelessNextEditProvider { tracer, duplicateAdditionsMode, fastYieldLineWithCursor, + splitPatchOnDiff, ), ); break; diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts similarity index 91% rename from extensions/copilot/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts rename to extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts index 72520d3743102..ae8ede7eb17ee 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts @@ -1087,4 +1087,106 @@ another_file.js: expect(edits[0].edit).toEqual(new LineReplacement(new LineRange(2, 3), [' let x = 1;'])); }); }); + + describe('splitReplacement', () => { + + it('splits a multi-hunk replacement into one replacement per changed region', () => { + // removed lines 2..6 (1-based), only lines 3 ("b") and 5 ("d") change. + const removedLines = ['a', 'b', 'c', 'd', 'e']; + const replacement = new LineReplacement(new LineRange(2, 7), ['a', 'B', 'c', 'D', 'e']); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, removedLines); + + expect(result).toEqual([ + new LineReplacement(new LineRange(3, 4), ['B']), + new LineReplacement(new LineRange(5, 6), ['D']), + ]); + }); + + it('leaves a single contiguous change unsplit', () => { + const removedLines = ['a', 'b', 'c']; + const replacement = new LineReplacement(new LineRange(10, 13), ['a', 'X', 'Y', 'c']); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, removedLines); + + expect(result).toEqual([replacement]); + }); + + it('leaves a pure insertion unsplit', () => { + const replacement = new LineReplacement(new LineRange(5, 5), ['x', 'y']); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, []); + + expect(result).toEqual([replacement]); + }); + + it('leaves a pure deletion unsplit', () => { + const replacement = new LineReplacement(new LineRange(5, 8), []); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, ['x', 'y', 'z']); + + expect(result).toEqual([replacement]); + }); + }); + + describe('handleResponse with splitPatchOnDiff', () => { + + const docContent = '0\na\nb\nc\nd\ne\n'; + + // A single patch block whose only real changes are line "b"->"B" and + // "d"->"D"; the lines in between are re-emitted context. + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '-a'; + yield '-b'; + yield '-c'; + yield '-d'; + yield '-e'; + yield '+a'; + yield '+B'; + yield '+c'; + yield '+D'; + yield '+e'; + } + + it('splits one coarse patch into minimal replacements when enabled', async () => { + const docId = DocumentId.create('file:///test.ts'); + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + false, + true, + ); + + expect(edits.map(e => e.edit)).toEqual([ + new LineReplacement(new LineRange(3, 4), ['B']), + new LineReplacement(new LineRange(5, 6), ['D']), + ]); + }); + + it('yields a single coarse replacement when disabled (default)', async () => { + const docId = DocumentId.create('file:///test.ts'); + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + ); + + expect(edits.map(e => e.edit)).toEqual([ + new LineReplacement(new LineRange(2, 7), ['a', 'B', 'c', 'D', 'e']), + ]); + }); + }); }); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 548e08874627e..01c2a48ee0883 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -886,6 +886,7 @@ export namespace ConfigKey { export const InlineEditsXtabMaxMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.maxMergeConflictLines', ConfigType.ExperimentBased, undefined); export const InlineEditsXtabOnlyMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.onlyMergeConflictLines', ConfigType.ExperimentBased, false); export const InlineEditsXtabDuplicateAdditionsMode = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffPatch.duplicateAdditionsMode', ConfigType.ExperimentBased, DuplicateAdditionsMode.Off, DuplicateAdditionsMode.VALIDATOR); + export const InlineEditsXtabSplitPatchOnDiff = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffPatch.splitOnDiff', ConfigType.ExperimentBased, false, vBoolean()); export const InlineEditsXtabAggressivenessLevel = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.aggressivenessLevel', ConfigType.ExperimentBased, undefined); export const InlineEditsAggressivenessLowMinResponseTimeMs = defineTeamInternalSetting('chat.advanced.inlineEdits.aggressiveness.lowMinResponseTimeMs', ConfigType.ExperimentBased, 1500); export const InlineEditsAggressivenessMediumMinResponseTimeMs = defineTeamInternalSetting('chat.advanced.inlineEdits.aggressiveness.mediumMinResponseTimeMs', ConfigType.ExperimentBased, 700); From b46758c929c71ddd6164e26ff4e16c9d2c670873 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 23 Jun 2026 19:30:09 +1000 Subject: [PATCH 019/696] chat: add full path + branch tooltip to agent-host folder picker chip (#322502) Agent Host changes for agents/agents-window-fix-proposal-7e747224 --- .../agentHostFolderPickerActionItem.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 8b51bf620501f..179da29adb99a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -6,6 +6,10 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IManagedHoverTooltipMarkdownString } from '../../../../../../base/browser/ui/hover/hover.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { basename } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -14,9 +18,11 @@ import { MenuItemAction } from '../../../../../../platform/actions/common/action import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { ISCMService } from '../../../../scm/common/scm.js'; import type { IChatWidget } from '../../chat.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from '../../widget/input/chatInputPickerActionItem.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; @@ -33,6 +39,8 @@ import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderSe */ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewItem { + private _hoverSetup = false; + constructor( action: MenuItemAction, private readonly _widget: IChatWidget, @@ -43,6 +51,8 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt @ITelemetryService telemetryService: ITelemetryService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, + @ISCMService private readonly _scmService: ISCMService, + @IHoverService private readonly _hoverService: IHoverService, ) { const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { @@ -129,4 +139,62 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt ); return null; } + + override render(container: HTMLElement): void { + super.render(container); + + // The chip often shows a disabled folder name — the working directory is + // fixed once the session starts — so a plain folder name alone doesn't + // explain what it represents. Surface a descriptive hover with the full + // folder path and current git branch, mirroring the agents window's + // session header. Set up once with a content factory so the hover always + // reflects the latest selection/branch at the time it is shown. + if (this.element && !this._hoverSetup) { + this._hoverSetup = true; + this._register(this._hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + this.element, + () => this._buildHoverContent(), + )); + } + } + + /** + * Builds the hover content for the folder chip: the full folder path and, + * when the folder maps to a git repository, the current branch name. + * Returns `undefined` when no folder is selected so no hover is shown. + */ + private _buildHoverContent(): IManagedHoverTooltipMarkdownString | undefined { + const selected = this._selectedFolder(); + if (!selected) { + return undefined; + } + + const md = new MarkdownString('', { supportThemeIcons: true }); + const fallbackLines: string[] = []; + + md.appendMarkdown(`$(${Codicon.folder.id}) `); + md.appendText(selected.fsPath); + fallbackLines.push(selected.fsPath); + + const branch = this._branchName(selected); + if (branch) { + md.appendMarkdown('\n\n$(git-branch) '); + md.appendText(branch); + fallbackLines.push(branch); + } + + return { markdown: md, markdownNotSupportedFallback: fallbackLines.join('\n') }; + } + + /** + * Resolves the current git branch name for the given folder via the SCM + * service, or `undefined` when the folder has no associated repository or + * branch information. + */ + private _branchName(folderUri: URI): string | undefined { + const repository = this._scmService.getRepository(folderUri); + const historyProvider = repository?.provider.historyProvider.get(); + return historyProvider?.historyItemRef.get()?.name.trim() || undefined; + } } From 25fc3f84f46f7319a6bc2ba8480b111d01d0b14b Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 23 Jun 2026 13:02:07 +0100 Subject: [PATCH 020/696] Adjust spacing and background colors in chat composite bar (#322530) style: adjust spacing and background colors in chat composite bar Co-authored-by: mrleemurray --- src/vs/sessions/browser/parts/media/chatCompositeBar.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index faaebe9bd3e6e..8db975354cd1f 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -253,7 +253,7 @@ display: flex; align-items: center; height: 100%; - gap: 0; + gap: 4px; } .chat-composite-bar-toolbar { @@ -296,6 +296,7 @@ line-height: 22px; height: 26px; border-radius: var(--vscode-cornerRadius-small); + background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 4%, transparent); user-select: none; flex-shrink: 0; max-width: var(--chat-tab-max-width); @@ -348,6 +349,7 @@ .chat-composite-bar-tab:hover { color: var(--chat-tab-active-foreground); + background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 8%, transparent); } /* Active state: background container instead of underline — mirrors auxiliarybar checked */ @@ -356,7 +358,7 @@ } .session-view.is-active .chat-composite-bar-tab.active { - background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 5%, transparent); + background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 10%, transparent); } /* Reduce right padding when close button is present to avoid excess spacing */ From 0862d26f38a46d02cd8c3cbc51043410965951e3 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:44:52 +0200 Subject: [PATCH 021/696] Add onboarding spotlight tour for the Agents window (#322468) * Add onboarding spotlight tour for the Agents window Introduce a presentation-agnostic onboarding scenario engine (vs/workbench/contrib/onboarding) with a reusable spotlight presentation (dim overlay, masked highlight, anchored callout) and a first Agents-window tour that highlights running sessions in parallel, the isolation picker and the workspace picker. The new-session tour triggers via an observable for new users after they send a request and the session stays visible, with an onboarding.developerMode setting to bypass usage gating for testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix leaked onAbort listener in onboarding shutdown test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: dedupe in-flight runScenario, fix focus trap and keyboard advance - runScenario joins an in-flight run instead of scheduling a second one - focus trap handles focus not being on a tracked element (no -1 wrap-around) - advanceOnTargetClick keeps the spotlighted target keyboard-reachable: it joins the focus trap, receives Tab/Esc handling, and gets initial focus so keyboard users can activate it to advance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI: register onboarding i18n namespaces and make focus-trap test deterministic - Add vs/workbench/contrib/onboarding and vs/sessions/contrib/onboardingTours to build/lib/i18n.resources.json (code-translation-remind eslint rule failed CI) - Set keyCode on the synthetic Tab event via Object.defineProperty so StandardKeyboardEvent recognizes it (init-dict keyCode is not honored), fixing the flaky markdown focus-trap assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI: use KeyMod.Shift for Shift+Tab in spotlight focus trap The focus trap matched 'KeyCode.Shift | KeyCode.Tab', a meaningless OR of two key codes, so Shift+Tab was never recognized and focus wasn't trapped. Use KeyMod.Shift | KeyCode.Tab. This fixes the failing focus-trap unit test and makes reverse focus navigation work for real keyboard users. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/i18n.resources.json | 8 + .../contrib/chat/browser/newChatWidget.ts | 4 + .../browser/newSessionButtonTarget.ts | 59 +++ .../browser/newSessionTourContribution.ts | 90 +++++ .../browser/onboardingTours.contribution.ts | 12 + .../browser/tours/newSessionTour.ts | 73 ++++ .../browser/isolationPicker.ts | 4 + .../browser/sessionsLifecycleTracker.ts | 2 +- src/vs/sessions/sessions.common.main.ts | 5 + .../onboarding/browser/media/spotlight.css | 105 ++++++ .../browser/onboarding.contribution.ts | 75 ++++ .../onboarding/browser/onboardingService.ts | 334 +++++++++++++++++ .../browser/spotlight/onboardingTarget.ts | 43 +++ .../browser/spotlight/spotlightOverlay.ts | 354 ++++++++++++++++++ .../spotlight/spotlightPresentation.ts | 156 ++++++++ .../browser/spotlight/spotlightTypes.ts | 67 ++++ .../common/onboardingPresentation.ts | 82 ++++ .../onboarding/common/onboardingRegistry.ts | 61 +++ .../onboarding/common/onboardingScenario.ts | 71 ++++ .../common/onboardingScenarioService.ts | 57 +++ .../test/browser/onboardingService.test.ts | 323 ++++++++++++++++ .../test/browser/spotlightOverlay.test.ts | 205 ++++++++++ src/vs/workbench/workbench.common.main.ts | 3 + 23 files changed, 2192 insertions(+), 1 deletion(-) create mode 100644 src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts create mode 100644 src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts create mode 100644 src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts create mode 100644 src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts create mode 100644 src/vs/workbench/contrib/onboarding/browser/media/spotlight.css create mode 100644 src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts create mode 100644 src/vs/workbench/contrib/onboarding/browser/onboardingService.ts create mode 100644 src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts create mode 100644 src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts create mode 100644 src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts create mode 100644 src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts create mode 100644 src/vs/workbench/contrib/onboarding/common/onboardingPresentation.ts create mode 100644 src/vs/workbench/contrib/onboarding/common/onboardingRegistry.ts create mode 100644 src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts create mode 100644 src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts create mode 100644 src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts create mode 100644 src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index e98614e34386f..e4a62e2c468f6 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -306,6 +306,10 @@ "name": "vs/workbench/contrib/welcomeOnboarding", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/onboarding", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/welcomePage", "project": "vscode-workbench" @@ -739,6 +743,10 @@ { "name": "vs/sessions/contrib/editor", "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/onboardingTours", + "project": "vscode-sessions" } ] } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 3aad8b445598d..6afcbd59257bc 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -27,6 +27,7 @@ import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; import { IChatViewOptions } from '../../../browser/parts/chatView.js'; +import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; // #region --- New Chat Widget --- @@ -143,6 +144,9 @@ export class NewChatWidget extends Disposable { this._aquariumToggle = this._register(this.aquariumService.mountToggle(element)); const workspacePickerContainer = dom.append(chatWidgetContent, dom.$('.new-session-workspace-picker-container')); + // Onboarding spotlight target — id is referenced by the "new session" tour + // in vs/sessions/contrib/onboardingTours. + this._register(markOnboardingTarget(workspacePickerContainer, 'sessions.newSession.workspacePicker')); // On web (vscode.dev / insiders.vscode.dev) the workspace picker is // scoped to the currently selected agent host. When no hosts are // known there is nothing for the user to pick, so swap the picker diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts new file mode 100644 index 0000000000000..050741f1da2e4 --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; +import { SessionSectionToolbarMenuId } from '../../sessions/browser/views/sessionsList.js'; + +/** Command id of the per-workspace-section "New Session" toolbar action. */ +const NEW_SESSION_COMMAND_ID = 'sessionsView.sectionNewSession'; + +/** Onboarding target id for the "New Session" button, referenced by the tour. */ +export const SESSIONS_NEW_SESSION_BUTTON_TARGET = 'sessions.newSession.button'; + +/** + * The "New Session" button is rendered by the sessions list section toolbar from + * a registered action, so it has no creation site we can tag directly. This view + * item renders like the default one and additionally marks its element as an + * onboarding target — registered via {@link IActionViewItemService} so it + * applies wherever that toolbar action is shown, without touching the renderer. + */ +class OnboardingNewSessionActionViewItem extends MenuEntryActionViewItem { + + private readonly _tag = this._register(new MutableDisposable()); + + override render(container: HTMLElement): void { + super.render(container); + if (this.element) { + this._tag.value = markOnboardingTarget(this.element, SESSIONS_NEW_SESSION_BUTTON_TARGET); + } + } +} + +class OnboardingNewSessionButtonTargetContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.onboardingTours.newSessionButtonTarget'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + this._register(actionViewItemService.register( + SessionSectionToolbarMenuId, + NEW_SESSION_COMMAND_ID, + (action: IAction, options: IActionViewItemOptions, instantiationService: IInstantiationService) => + instantiationService.createInstance(OnboardingNewSessionActionViewItem, action as MenuItemAction, options), + )); + } +} + +registerWorkbenchContribution2(OnboardingNewSessionButtonTargetContribution.ID, OnboardingNewSessionButtonTargetContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts new file mode 100644 index 0000000000000..6e463e7cc1326 --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../base/common/async.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { onboardingScenarioRegistry } from '../../../../workbench/contrib/onboarding/common/onboardingRegistry.js'; +import { ONBOARDING_DEVELOPER_MODE_CONFIG } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; +import { createNewSessionTour } from './tours/newSessionTour.js'; + +/** + * Registers the "new session" onboarding tour and decides *when* it should run. + * + * The tour targets brand-new users: it only triggers while the number of + * sessions the user has ever started (persisted by the sessions telemetry + * tracker under {@link TOTAL_SESSIONS_KEY}) is below {@link MAX_SESSIONS_FOR_TOUR}. + * When an eligible user sends a request, we wait {@link VISIBILITY_DELAY_MS} and + * only then flip the tour's trigger signal — and only if that session is still + * visible in the sessions grid (so we don't interrupt a session the user + * immediately closed or navigated away from). The onboarding engine handles + * showing the tour at most once. + * + * The `onboarding.developerMode` setting bypasses the session-count gate so the + * tour can be triggered on demand for testing. + */ +class NewSessionTourContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.onboardingTours.newSessionTour'; + + /** Only nudge users who are still in their first few sessions. */ + private static readonly MAX_SESSIONS_FOR_TOUR = 3; + /** Delay after a request before checking the session is still visible. */ + private static readonly VISIBILITY_DELAY_MS = 10_000; + + /** Drives the tour's `observable` trigger. Flipped to `true` exactly once. */ + private readonly _trigger = observableValue(this, false); + + private readonly _pendingCheck = this._register(new MutableDisposable()); + + constructor( + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @ISessionsService private readonly sessionsService: ISessionsService, + @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + + this._register(onboardingScenarioRegistry.register(createNewSessionTour(this._trigger))); + + this._register(sessionsManagementService.onDidSendRequest(e => this._onDidSendRequest(e.session))); + } + + private _onDidSendRequest(session: ISession): void { + // Already triggered (or about to be shown) — nothing more to do. + if (this._trigger.get()) { + this._pendingCheck.clear(); + return; + } + + // The developer setting bypasses the usage-based "first few sessions" + // gate so the tour can be triggered on demand for testing. + const developerMode = this.configurationService.getValue(ONBOARDING_DEVELOPER_MODE_CONFIG) === true; + if (!developerMode) { + const sessionsStarted = this.storageService.getNumber(TOTAL_SESSIONS_KEY, StorageScope.APPLICATION, 0); + if (sessionsStarted >= NewSessionTourContribution.MAX_SESSIONS_FOR_TOUR) { + return; + } + } + + // Wait, then only trigger if the user is still looking at this session in + // the grid. A new request restarts the timer for the latest session. + this._pendingCheck.value = disposableTimeout(() => { + const stillVisible = this.sessionsService.visibleSessions.get().some(s => s?.sessionId === session.sessionId); + if (stillVisible) { + this._trigger.set(true, undefined); + } + }, NewSessionTourContribution.VISIBILITY_DELAY_MS); + } +} + +registerWorkbenchContribution2(NewSessionTourContribution.ID, NewSessionTourContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts new file mode 100644 index 0000000000000..f946de2dff62d --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Registers the Agents window onboarding tours. The imported modules register +// the tour targets and the tour contribution (which owns the scenario and its +// trigger) as a side effect. The onboarding engine and the spotlight +// presentation live in `vs/workbench/contrib/onboarding` and are booted from +// the workbench contribution imported in the entry point. +import './newSessionButtonTarget.js'; +import './newSessionTourContribution.js'; diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts new file mode 100644 index 0000000000000..032ac715bf141 --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IOnboardingScenario } from '../../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; +import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; +import { localize } from '../../../../../nls.js'; + +/** + * Onboarding tour shown once the user has created their first session (the chat + * view is visible, not the new-session view). It teaches running sessions in + * parallel and the workspace/isolation options: + * + * 1. The "New Session" button in the sessions list — advancing when the user + * presses it, which opens the new-session view that hosts steps 2 & 3. + * 2. The isolation picker (worktree vs folder). + * 3. The workspace picker. + * + * Each `targetId` matches a `data-onboarding-id` set via `markOnboardingTarget`: + * - `sessions.newSession.button` — newSessionButtonTarget.ts (this contrib) + * - `sessions.newSession.isolation` — contrib/providers/copilotChatSessions/browser/isolationPicker.ts + * - `sessions.newSession.workspacePicker` — contrib/chat/browser/newChatWidget.ts + */ +const newSessionPayload: ISpotlightPayload = { + steps: [ + { + id: 'newSessionButton', + targetId: 'sessions.newSession.button', + title: localize('sessions.onboarding.parallel.title', "Run Sessions in Parallel"), + description: localize('sessions.onboarding.parallel.description', "Start another session to work on a different task at the same time. The Agents window runs multiple sessions in parallel."), + placement: 'right', + // Advance when the user actually presses the button, which opens the + // new-session view that hosts the next two steps. + advanceOnTargetClick: true, + }, + { + id: 'isolation', + targetId: 'sessions.newSession.isolation', + title: localize('sessions.onboarding.isolation.title', "Isolate Your Work"), + description: localize('sessions.onboarding.isolation.description', "Choose a worktree to work on two different tasks in the same workspace while keeping the two tasks fully isolated from each other."), + placement: 'above', + }, + { + id: 'workspacePicker', + targetId: 'sessions.newSession.workspacePicker', + title: localize('sessions.onboarding.workspace.title', "Work Across Workspaces"), + description: localize('sessions.onboarding.workspace.description', "Pick any workspace — you can run multiple tasks in the same workspace as well as across several different workspaces at the same time."), + placement: 'above', + }, + ], +}; + +/** + * Builds the "new session" tour scenario. The provided `signal` controls *when* + * the tour becomes eligible — it is driven by {@link NewSessionTourContribution}, + * which only flips it on for new users a short while after they send a request. + * `ChatContextKeys.enabled` keeps the tour hidden when AI features are disabled. + */ +export function createNewSessionTour(signal: IObservable): IOnboardingScenario { + return { + id: 'sessions.onboarding.newSession', + when: ChatContextKeys.enabled, + trigger: { kind: 'observable', signal }, + priority: 100, + presentation: { + kind: SPOTLIGHT_PRESENTATION_KIND, + payload: newSessionPayload, + }, + }; +} diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts index c6ee4b7da10e1..955b1db9ca3cb 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts @@ -18,6 +18,7 @@ import { reportNewChatPickerClosed } from '../../../chat/browser/newChatPickerTe import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; +import { markOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; export type IsolationMode = 'worktree' | 'workspace'; @@ -100,6 +101,9 @@ export class IsolationPicker extends Disposable { const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); this._renderDisposables.add({ dispose: () => slot.remove() }); this._slotElement = slot; + // Onboarding spotlight target — id is referenced by the "new session" tour + // in vs/sessions/contrib/onboardingTours. + this._renderDisposables.add(markOnboardingTarget(slot, 'sessions.newSession.isolation')); const trigger = dom.append(slot, dom.$('a.action-label')); trigger.tabIndex = 0; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts index e850175c48f2e..8dbecb09d78d6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts @@ -14,7 +14,7 @@ const APP_LAUNCH_COUNT_KEY = 'agentSessions.telemetry.summary.appLaunchCount'; /** Storage key for the per-session lifecycle stats map (JSON encoded). Exported for tests. */ export const SESSIONS_KEY = 'agentSessions.telemetry.summary.sessions'; /** Storage key for the cumulative number of sessions started from the Agents window across all workspaces and providers. */ -const TOTAL_SESSIONS_KEY = 'agentSessions.telemetry.totalSessions'; +export const TOTAL_SESSIONS_KEY = 'agentSessions.telemetry.totalSessions'; /** Storage key for the cumulative number of sessions started in each workspace (JSON encoded map of workspace URI -> count). */ const WORKSPACE_SESSIONS_KEY = 'agentSessions.telemetry.workspaceSessions'; /** Storage key for the cumulative number of sessions started for each sessions provider (JSON encoded map of providerId -> count). */ diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 636b6820cfb62..64c6c42d12c92 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -482,5 +482,10 @@ import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/aquarium/browser/aquarium.contribution.js'; import './contrib/policyBlocked/browser/policyBlocked.contribution.js'; +// Onboarding: the engine + spotlight presentation (from the workbench layer) and +// the Agents window scenario data. +import '../workbench/contrib/onboarding/browser/onboarding.contribution.js'; +import './contrib/onboardingTours/browser/onboardingTours.contribution.js'; + import './services/sessions/browser/sessionsManagementService.js'; //#endregion diff --git a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css new file mode 100644 index 0000000000000..50641243c71cf --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Spotlight overlay: dim everything, cut a highlight hole, anchor a callout. */ + +.spotlight-overlay { + position: fixed; + inset: 0; + /* Above dialogs (2575) so the onboarding spotlight wins over modal chrome. */ + z-index: 2600; +} + +/* Full-screen click blocker. Transparent; swallows interaction outside the hole. */ +.spotlight-blocker { + position: fixed; + inset: 0; + pointer-events: auto; +} + +/* + * The highlight hole. The huge spread box-shadow paints the dim everywhere + * *outside* this element, while the inset ring highlights the target. Purely + * visual: it never intercepts pointer events. + */ +.spotlight-hole { + position: fixed; + border-radius: var(--vscode-cornerRadius-medium); + pointer-events: none; + /* + * The dim color matches the modal scrim (.monaco-dialog-modal-block.dimmed) + * and the native window-controls dim (windowImpl.ts dimColor, 0.5 black) so + * the spotlight, the dimmed app and the OS-drawn controls read as one layer. + */ + box-shadow: + 0 0 0 9999px rgba(0, 0, 0, 0.5), + 0 0 0 2px var(--vscode-focusBorder) inset; + transition: top 0.2s ease, left 0.2s ease, width 0.2s ease, height 0.2s ease; +} + +.spotlight-callout { + position: fixed; + box-sizing: border-box; + width: 320px; + max-width: calc(100vw - var(--vscode-spacing-size320)); + padding: var(--vscode-spacing-size160); + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border, transparent); + border-radius: var(--vscode-cornerRadius-large); + box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36)); + pointer-events: auto; +} + +.spotlight-callout:focus, +.spotlight-callout:focus-visible { + outline: none; +} + +.spotlight-callout-title { + margin: 0 0 var(--vscode-spacing-size80) 0; + font-size: var(--vscode-bodyFontSize); + font-weight: 600; +} + +.spotlight-callout-description { + margin: 0; + font-size: var(--vscode-bodyFontSize); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.spotlight-callout-description p { + margin: 0 0 var(--vscode-spacing-size80) 0; +} + +.spotlight-callout-description p:last-child { + margin-bottom: 0; +} + +.spotlight-callout-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--vscode-spacing-size120); + margin-top: var(--vscode-spacing-size160); +} + +.spotlight-callout-counter { + font-size: var(--vscode-bodyFontSize-small); + color: var(--vscode-descriptionForeground); +} + +.spotlight-callout-actions { + display: flex; + gap: var(--vscode-spacing-size80); +} + +/* Respect reduced-motion: drop the animated hole transition. */ +@media (prefers-reduced-motion: reduce) { + .spotlight-hole { + transition: none; + } +} diff --git a/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts new file mode 100644 index 0000000000000..b28ea9e9e6f06 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.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 { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { onboardingPresentationRegistry } from '../common/onboardingPresentation.js'; +import { IOnboardingScenarioService, ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_ENABLED_CONFIG } from '../common/onboardingScenarioService.js'; +import { OnboardingScenarioService } from './onboardingService.js'; +import { SpotlightPresentation } from './spotlight/spotlightPresentation.js'; + +registerSingleton(IOnboardingScenarioService, OnboardingScenarioService, InstantiationType.Delayed); + +/** + * Boots the onboarding engine after the workbench has restored: registers the + * built-in presentations and starts evaluating registered scenarios. + */ +class OnboardingContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.onboarding'; + + constructor( + @IOnboardingScenarioService onboardingService: IOnboardingScenarioService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + const spotlight = this._register(instantiationService.createInstance(SpotlightPresentation)); + this._register(onboardingPresentationRegistry.register(spotlight)); + onboardingService.start(); + } +} + +registerWorkbenchContribution2(OnboardingContribution.ID, OnboardingContribution, WorkbenchPhase.AfterRestored); + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + [ONBOARDING_ENABLED_CONFIG]: { + type: 'boolean', + default: false, + description: localize('onboarding.enabled', "When enabled, onboarding tours and hints may appear automatically to highlight features. Disabling this does not affect tours you start manually.") + }, + [ONBOARDING_DEVELOPER_MODE_CONFIG]: { + type: 'boolean', + default: false, + tags: ['experimental'], + description: localize('onboarding.developerMode', "When enabled, onboarding tours ignore usage-based eligibility checks (such as how many sessions you have started) so they can be triggered for testing. Tours are still shown at most once; use the \"Reset Onboarding Shown State\" command to replay them.") + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.onboarding.resetShownState', + title: localize2('onboarding.resetShownState', "Reset Onboarding Shown State"), + category: Categories.Developer, + f1: true, + }); + } + + run(accessor: ServicesAccessor): void { + accessor.get(IOnboardingScenarioService).resetAll(); + } +}); diff --git a/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts b/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts new file mode 100644 index 0000000000000..34a676f6d028a --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts @@ -0,0 +1,334 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; +import { Memento } from '../../../common/memento.js'; +import { onboardingPresentationRegistry } from '../common/onboardingPresentation.js'; +import { onboardingScenarioRegistry } from '../common/onboardingRegistry.js'; +import { IOnboardingScenario, OnboardingOutcome } from '../common/onboardingScenario.js'; +import { IOnboardingScenarioService, ONBOARDING_ENABLED_CONFIG } from '../common/onboardingScenarioService.js'; + +/** Persisted "shown" state for a single scenario. */ +interface IScenarioState { + readonly shownAt: number; + outcome?: OnboardingOutcome; + seenCount: number; +} + +type OnboardingMementoData = { [scenarioId: string]: IScenarioState }; + +export class OnboardingScenarioService extends Disposable implements IOnboardingScenarioService { + + declare readonly _serviceBrand: undefined; + + private static readonly MEMENTO_ID = 'onboarding'; + + private readonly _memento: Memento; + private readonly _state: Partial; + + /** Listeners for `observable` triggers, rebuilt whenever the registry changes. */ + private readonly _triggerListeners = this._register(new DisposableStore()); + + /** Scenario ids currently queued or running (prevents double-scheduling). */ + private readonly _pending = new Set(); + private readonly _queue: { scenario: IOnboardingScenario; deferred: DeferredPromise }[] = []; + /** Deferreds for scenarios that have been dequeued and are currently running, keyed by id. */ + private readonly _inflight = new Map>(); + private _pumping = false; + + /** Abort signal for the scenario currently running. */ + private _activeAbort: Emitter | undefined; + + /** Cached experiment treatment results for scenarios that declare one. */ + private readonly _experimentResults = new Map(); + + private _started = false; + private _stopped = false; + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, + ) { + super(); + + this._memento = new Memento(OnboardingScenarioService.MEMENTO_ID, this.storageService); + this._state = this._memento.getMemento(StorageScope.PROFILE, StorageTarget.USER); + + // On shutdown abort the active run and drain anything still queued so no + // fresh overlay is mounted while the window is going away. + this._register(this.lifecycleService.onWillShutdown(() => this._stop())); + } + + private _stop(): void { + this._stopped = true; + this._activeAbort?.fire(); + + let entry: { scenario: IOnboardingScenario; deferred: DeferredPromise } | undefined; + while ((entry = this._queue.shift())) { + this._pending.delete(entry.scenario.id); + entry.deferred.complete(OnboardingOutcome.Aborted); + } + } + + start(): void { + if (this._started) { + return; + } + this._started = true; + + this._register(onboardingScenarioRegistry.onDidChange(() => { + this._registerTriggerListeners(); + this._fetchExperiments(); + this._evaluate(); + })); + + this._register(this.contextKeyService.onDidChangeContext(() => this._evaluate())); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ONBOARDING_ENABLED_CONFIG)) { + this._evaluate(); + } + })); + + this._registerTriggerListeners(); + this._fetchExperiments(); + this._evaluate(); + } + + getScenarios(): readonly IOnboardingScenario[] { + return onboardingScenarioRegistry.getScenarios(); + } + + async runScenario(id: string): Promise { + const scenario = onboardingScenarioRegistry.getScenario(id); + if (!scenario) { + throw new Error(`Unknown onboarding scenario '${id}'.`); + } + return this._enqueue(scenario); + } + + hasBeenShown(id: string): boolean { + return !!this._state[id]?.shownAt; + } + + reset(id: string): void { + delete this._state[id]; + this._memento.saveMemento(); + } + + resetAll(): void { + for (const key of Object.keys(this._state)) { + delete this._state[key]; + } + this._memento.saveMemento(); + } + + //#region Eligibility & scheduling + + private get _enabled(): boolean { + return this.configurationService.getValue(ONBOARDING_ENABLED_CONFIG) !== false; + } + + /** + * Re-evaluate every scenario and enqueue any that are eligible to run + * automatically. Idempotent: already shown / queued scenarios are skipped. + */ + private _evaluate(): void { + if (!this._enabled || this._stopped) { + return; + } + + const eligible = onboardingScenarioRegistry.getScenarios() + .filter(scenario => this._isAutoEligible(scenario)); + + for (const scenario of eligible) { + this._enqueue(scenario); + } + } + + private _isAutoEligible(scenario: IOnboardingScenario): boolean { + // `command` triggers never run automatically. + if (scenario.trigger.kind === 'command') { + return false; + } + + if (this._pending.has(scenario.id)) { + return false; + } + + if (!scenario.repeatable && this.hasBeenShown(scenario.id)) { + return false; + } + + if (scenario.when && !this.contextKeyService.contextMatchesRules(scenario.when)) { + return false; + } + + if (scenario.experiment && this._experimentResults.get(scenario.experiment) !== true) { + return false; + } + + if (scenario.trigger.kind === 'observable' && scenario.trigger.signal.get() !== true) { + return false; + } + + return true; + } + + private _enqueue(scenario: IOnboardingScenario): Promise { + if (this._stopped) { + return Promise.resolve(OnboardingOutcome.Aborted); + } + + // De-duplicate against both the queue and the in-flight run so a repeated + // `runScenario(id)` (e.g. a command invoked while the tour is active) + // joins the existing run instead of scheduling a second one. + const queued = this._queue.find(entry => entry.scenario.id === scenario.id); + if (queued) { + return queued.deferred.p; + } + const inflight = this._inflight.get(scenario.id); + if (inflight) { + return inflight.p; + } + + const deferred = new DeferredPromise(); + this._pending.add(scenario.id); + this._queue.push({ scenario, deferred }); + // Highest priority first; stable for equal priorities. + this._queue.sort((a, b) => (b.scenario.priority ?? 0) - (a.scenario.priority ?? 0)); + + this._pump(); + return deferred.p; + } + + private _pump(): void { + if (this._pumping) { + return; + } + // Mark as pumping synchronously so a batch of `_enqueue` calls made in the + // same tick all land (and re-sort by priority) before we consume the queue. + this._pumping = true; + this._doPump(); + } + + private async _doPump(): Promise { + await Promise.resolve(); // let the current synchronous batch of enqueues settle + try { + let entry: { scenario: IOnboardingScenario; deferred: DeferredPromise } | undefined; + while (!this._stopped && (entry = this._queue.shift())) { + const { scenario, deferred } = entry; + // Track the running scenario so a concurrent `_enqueue` for the same + // id joins this run instead of scheduling another. + this._inflight.set(scenario.id, deferred); + let outcome: OnboardingOutcome; + try { + outcome = await this._runPresentation(scenario); + } catch (error) { + onUnexpectedError(error); + outcome = OnboardingOutcome.Aborted; + } finally { + this._inflight.delete(scenario.id); + this._pending.delete(scenario.id); + } + deferred.complete(outcome); + } + } finally { + this._pumping = false; + } + } + + private async _runPresentation(scenario: IOnboardingScenario): Promise { + const presentation = onboardingPresentationRegistry.get(scenario.presentation.kind); + if (!presentation) { + return OnboardingOutcome.Aborted; + } + + // Mark shown the moment a scenario starts so a crash/reload won't re-trigger it. + this._markShown(scenario.id); + + const abort = new Emitter(); + this._activeAbort = abort; + try { + const outcome = await presentation.run(scenario, { targetWindow: mainWindow, onAbort: abort.event }); + this._recordOutcome(scenario.id, outcome); + return outcome; + } finally { + this._activeAbort = undefined; + abort.dispose(); + } + } + + //#endregion + + //#region Triggers & experiments + + private _registerTriggerListeners(): void { + this._triggerListeners.clear(); + for (const scenario of onboardingScenarioRegistry.getScenarios()) { + if (scenario.trigger.kind === 'observable') { + const signal = scenario.trigger.signal; + this._triggerListeners.add(autorun(reader => { + signal.read(reader); + this._evaluate(); + })); + } + } + } + + private _fetchExperiments(): void { + for (const scenario of onboardingScenarioRegistry.getScenarios()) { + const id = scenario.experiment; + if (!id || this._experimentResults.has(id)) { + continue; + } + this._experimentResults.set(id, false); + this.assignmentService.getTreatment(id).then(value => { + if (value === true) { + this._experimentResults.set(id, true); + this._evaluate(); + } + }, error => onUnexpectedError(error)); + } + } + + //#endregion + + //#region Persistence + + private _markShown(id: string): void { + const previous = this._state[id]; + const next: IScenarioState = { + shownAt: Date.now(), + outcome: previous?.outcome, + seenCount: (previous?.seenCount ?? 0) + 1 + }; + this._state[id] = next; + this._memento.saveMemento(); + } + + private _recordOutcome(id: string, outcome: OnboardingOutcome): void { + const state = this._state[id]; + if (state) { + state.outcome = outcome; + this._memento.saveMemento(); + } + } + + //#endregion +} diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts new file mode 100644 index 0000000000000..58c99fefb2e4e --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; + +/** + * Attribute used to mark a DOM element as an onboarding spotlight target. + * + * Components opt in by tagging their *own* element with this attribute. Tours + * reference the id; they never reach into another component's markup or CSS + * classes to locate a target (see the sessions "DOM Traversal & Intent" rule). + */ +export const ONBOARDING_TARGET_ATTR = 'data-onboarding-id'; + +/** + * Marks `element` as the onboarding target identified by `id`. + * + * @returns A disposable that removes the attribute again. + */ +export function markOnboardingTarget(element: HTMLElement, id: string): IDisposable { + element.setAttribute(ONBOARDING_TARGET_ATTR, id); + return toDisposable(() => { + if (element.getAttribute(ONBOARDING_TARGET_ATTR) === id) { + element.removeAttribute(ONBOARDING_TARGET_ATTR); + } + }); +} + +/** + * Resolves the element marked with the given onboarding target id within the + * provided window's document. Returns `undefined` if no such element exists + * (e.g. the feature is not currently rendered). + * + * This is the *only* place onboarding queries the DOM, and it matches solely on + * the onboarding attribute — never on foreign classes or structure. + */ +export function findOnboardingTarget(targetWindow: Window, id: string): HTMLElement | undefined { + const selector = `[${ONBOARDING_TARGET_ATTR}="${CSS.escape(id)}"]`; + // eslint-disable-next-line no-restricted-syntax -- matching only our own onboarding attribute (never foreign classes/structure) is the whole point of this helper + return targetWindow.document.querySelector(selector) ?? undefined; +} diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts new file mode 100644 index 0000000000000..0aaa62776fcc8 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts @@ -0,0 +1,354 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, append, EventType, getActiveElement, getWindow, isHTMLElement, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IMarkdownString, isMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { AnchorAlignment, AnchorAxisAlignment, AnchorPosition, IRect, layout2d } from '../../../../../base/common/layout.js'; +import { renderMarkdown } from '../../../../../base/browser/markdownRenderer.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { localize } from '../../../../../nls.js'; +import { SpotlightPlacement } from './spotlightTypes.js'; +import '../media/spotlight.css'; + +/** Default padding (px) added around the target when cutting the highlight hole. */ +const DEFAULT_HOLE_PADDING = 6; + +/** Content rendered inside the spotlight callout for a single step. */ +export interface ISpotlightContent { + readonly title: string; + readonly description: string | IMarkdownString; + /** Zero-based index of the current step. */ + readonly stepIndex: number; + /** Total number of steps in the tour. */ + readonly stepCount: number; + /** Whether a "Back" action should be offered. */ + readonly canGoBack: boolean; + /** Whether this is the final step (the primary button becomes "Done"). */ + readonly isLastStep: boolean; +} + +/** Options controlling how a step is shown. */ +export interface ISpotlightShowOptions { + readonly placement?: SpotlightPlacement; + readonly allowTargetInteraction?: boolean; + readonly padding?: number; + /** + * When set, the step advances (fires `onDidClickNext`) when the user clicks + * the spotlighted target itself. The "Next" button is hidden and the target + * is kept interactive so the user can press it to continue. + */ + readonly advanceOnTargetClick?: boolean; +} + +/** + * A pure-DOM spotlight overlay: dims the window, cuts a highlight hole around a + * target element and shows an anchored callout. It owns no VS Code services so + * it can be unit-tested and reused. Scheduling and content come from the + * spotlight presentation. + */ +export class SpotlightOverlay extends Disposable { + + private readonly _root: HTMLElement; + private readonly _blocker: HTMLElement; + private readonly _hole: HTMLElement; + private readonly _callout: HTMLElement; + + private readonly _title: HTMLElement; + private readonly _description: HTMLElement; + private readonly _counter: HTMLElement; + private readonly _descriptionRenderStore = this._register(new DisposableStore()); + + private readonly _backButton: Button; + private readonly _nextButton: Button; + private readonly _skipButton: Button; + + /** Listeners scoped to the currently shown step (re-layout sources). */ + private readonly _stepListeners = this._register(new DisposableStore()); + + private readonly _onDidClickNext = this._register(new Emitter()); + readonly onDidClickNext: Event = this._onDidClickNext.event; + + private readonly _onDidClickPrevious = this._register(new Emitter()); + readonly onDidClickPrevious: Event = this._onDidClickPrevious.event; + + private readonly _onDidSkip = this._register(new Emitter()); + readonly onDidSkip: Event = this._onDidSkip.event; + + private _target: HTMLElement | undefined; + private _options: ISpotlightShowOptions = {}; + private _previousFocus: HTMLElement | undefined; + private _scheduledLayout: IDisposable | undefined; + + constructor( + private readonly _container: HTMLElement, + private readonly _resizeObserverCtor: typeof ResizeObserver = getWindow(_container).ResizeObserver, + ) { + super(); + + this._root = append(this._container, $('.spotlight-overlay')); + this._root.style.display = 'none'; + + this._blocker = append(this._root, $('.spotlight-blocker')); + this._hole = append(this._root, $('.spotlight-hole')); + + this._callout = append(this._root, $('.spotlight-callout')); + this._callout.setAttribute('role', 'dialog'); + this._callout.setAttribute('aria-modal', 'true'); + this._callout.tabIndex = -1; + + const header = append(this._callout, $('.spotlight-callout-header')); + this._title = append(header, $('h2.spotlight-callout-title')); + this._title.id = 'spotlight-callout-title'; + this._callout.setAttribute('aria-labelledby', this._title.id); + + this._description = append(this._callout, $('.spotlight-callout-description')); + this._description.id = 'spotlight-callout-description'; + this._callout.setAttribute('aria-describedby', this._description.id); + + const footer = append(this._callout, $('.spotlight-callout-footer')); + this._counter = append(footer, $('.spotlight-callout-counter')); + const actions = append(footer, $('.spotlight-callout-actions')); + + this._skipButton = this._register(new Button(actions, { ...defaultButtonStyles, secondary: true })); + this._skipButton.label = localize('spotlight.skip', "Skip"); + this._register(this._skipButton.onDidClick(() => this._onDidSkip.fire())); + + this._backButton = this._register(new Button(actions, { ...defaultButtonStyles, secondary: true })); + this._backButton.label = localize('spotlight.back', "Back"); + this._register(this._backButton.onDidClick(() => this._onDidClickPrevious.fire())); + + this._nextButton = this._register(new Button(actions, { ...defaultButtonStyles })); + this._nextButton.label = localize('spotlight.next', "Next"); + this._register(this._nextButton.onDidClick(() => this._onDidClickNext.fire())); + + // Keyboard handling on the callout: Esc skips, focus is trapped within. + this._register(addDisposableListener(this._callout, EventType.KEY_DOWN, e => this._onKeyDown(e))); + + this._register({ dispose: () => this._restoreFocus() }); + } + + /** Show `content` spotlighting `target`. */ + show(target: HTMLElement, content: ISpotlightContent, options: ISpotlightShowOptions = {}): void { + const isFirstShow = this._root.style.display === 'none'; + if (isFirstShow) { + this._previousFocus = isHTMLElement(getActiveElement()) ? getActiveElement() as HTMLElement : undefined; + } + + this._target = target; + this._options = options; + this._renderContent(content); + + this._root.style.display = ''; + + // Rebuild the per-step re-layout listeners. + this._stepListeners.clear(); + const targetWindow = getWindow(this._container); + + const observer = new this._resizeObserverCtor(() => this._scheduleLayout()); + observer.observe(target); + observer.observe(this._container); + this._stepListeners.add({ dispose: () => observer.disconnect() }); + + this._stepListeners.add(addDisposableListener(targetWindow, EventType.RESIZE, () => this._scheduleLayout())); + this._stepListeners.add(addDisposableListener(targetWindow, EventType.SCROLL, () => this._scheduleLayout(), true)); + + // Cancel any pending scheduled frame when the step changes. Registered + // once here (not per schedule) so high-frequency scroll/resize events + // don't accumulate no-op disposables in `_stepListeners`. + this._stepListeners.add(toDisposable(() => { + this._scheduledLayout?.dispose(); + this._scheduledLayout = undefined; + })); + + // When the step advances by pressing the target, hide the Next button and + // advance on a click of the (interactive) target instead. The target is + // kept keyboard-reachable: it joins the focus trap (see `_collectFocusable`) + // and we route Tab/Esc from it through the same handler, so keyboard-only + // users can focus the spotlighted control and activate it to advance. + const advanceOnTargetClick = !!options.advanceOnTargetClick; + this._nextButton.element.style.display = advanceOnTargetClick ? 'none' : ''; + if (advanceOnTargetClick) { + this._stepListeners.add(addDisposableListener(target, EventType.CLICK, () => this._onDidClickNext.fire())); + this._stepListeners.add(addDisposableListener(target, EventType.KEY_DOWN, e => this._onKeyDown(e))); + } + + this.layout(); + + // Move focus to the spotlighted control (so keyboard users can activate it + // to advance) or, otherwise, into the callout's primary action. + (advanceOnTargetClick ? target : this._nextButton.element).focus(); + } + + /** Recompute the hole and callout positions for the current target. */ + layout(): void { + const target = this._target; + if (!target || this._root.style.display === 'none') { + return; + } + + const targetWindow = getWindow(this._container); + const viewportWidth = targetWindow.document.documentElement.clientWidth; + const viewportHeight = targetWindow.document.documentElement.clientHeight; + + const rect = target.getBoundingClientRect(); + const padding = this._options.padding ?? DEFAULT_HOLE_PADDING; + const holeLeft = Math.max(0, rect.left - padding); + const holeTop = Math.max(0, rect.top - padding); + const holeWidth = Math.min(viewportWidth - holeLeft, rect.width + padding * 2); + const holeHeight = Math.min(viewportHeight - holeTop, rect.height + padding * 2); + + this._hole.style.left = `${holeLeft}px`; + this._hole.style.top = `${holeTop}px`; + this._hole.style.width = `${holeWidth}px`; + this._hole.style.height = `${holeHeight}px`; + + // When the target is interactive (explicitly, or because the step advances + // on a target click), cut the hole out of the click blocker so events + // inside it reach the underlying element. + if (this._options.allowTargetInteraction || this._options.advanceOnTargetClick) { + const right = holeLeft + holeWidth; + const bottom = holeTop + holeHeight; + this._blocker.style.clipPath = `path(evenodd, 'M0 0 H${viewportWidth} V${viewportHeight} H0 Z M${holeLeft} ${holeTop} H${right} V${bottom} H${holeLeft} Z')`; + } else { + this._blocker.style.clipPath = ''; + } + + this._layoutCallout({ top: holeTop, left: holeLeft, width: holeWidth, height: holeHeight }, viewportWidth, viewportHeight); + } + + private _layoutCallout(anchor: IRect, viewportWidth: number, viewportHeight: number): void { + const viewport: IRect = { top: 0, left: 0, width: viewportWidth, height: viewportHeight }; + const view = { width: this._callout.offsetWidth, height: this._callout.offsetHeight }; + + const { anchorAxisAlignment, anchorPosition, anchorAlignment } = this._resolvePlacement(this._options.placement ?? 'auto'); + const result = layout2d(viewport, view, anchor, { anchorAxisAlignment, anchorPosition, anchorAlignment }); + + this._callout.style.top = `${result.top}px`; + this._callout.style.left = `${result.left}px`; + } + + private _resolvePlacement(placement: SpotlightPlacement): { anchorAxisAlignment: AnchorAxisAlignment; anchorPosition: AnchorPosition; anchorAlignment: AnchorAlignment } { + switch (placement) { + case 'above': + return { anchorAxisAlignment: AnchorAxisAlignment.VERTICAL, anchorPosition: AnchorPosition.ABOVE, anchorAlignment: AnchorAlignment.LEFT }; + case 'left': + return { anchorAxisAlignment: AnchorAxisAlignment.HORIZONTAL, anchorPosition: AnchorPosition.BELOW, anchorAlignment: AnchorAlignment.RIGHT }; + case 'right': + return { anchorAxisAlignment: AnchorAxisAlignment.HORIZONTAL, anchorPosition: AnchorPosition.BELOW, anchorAlignment: AnchorAlignment.LEFT }; + case 'below': + case 'auto': + default: + return { anchorAxisAlignment: AnchorAxisAlignment.VERTICAL, anchorPosition: AnchorPosition.BELOW, anchorAlignment: AnchorAlignment.LEFT }; + } + } + + private _renderContent(content: ISpotlightContent): void { + this._title.textContent = content.title; + + this._descriptionRenderStore.clear(); + this._description.replaceChildren(); + if (isMarkdownString(content.description)) { + const rendered = this._descriptionRenderStore.add(renderMarkdown(content.description)); + this._description.appendChild(rendered.element); + } else { + this._description.textContent = content.description; + } + + this._counter.textContent = localize('spotlight.counter', "{0} of {1}", content.stepIndex + 1, content.stepCount); + + this._backButton.element.style.display = content.canGoBack ? '' : 'none'; + this._nextButton.label = content.isLastStep + ? localize('spotlight.done', "Done") + : localize('spotlight.next', "Next"); + } + + private _onKeyDown(e: KeyboardEvent): void { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Escape)) { + event.stopPropagation(); + event.preventDefault(); + this._onDidSkip.fire(); + return; + } + + if (event.equals(KeyCode.Tab) || event.equals(KeyMod.Shift | KeyCode.Tab)) { + this._trapFocus(event); + } + } + + private _trapFocus(event: StandardKeyboardEvent): void { + const focusable = this._collectFocusable(); + if (focusable.length === 0) { + return; + } + + const active = getActiveElement(); + const currentIndex = focusable.findIndex(element => element === active); + + // When focus isn't currently on a tracked element (e.g. it landed on the + // callout container itself), start from the appropriate end so Tab goes to + // the first element and Shift+Tab to the last. + let nextIndex: number; + if (currentIndex === -1) { + nextIndex = event.shiftKey ? focusable.length - 1 : 0; + } else { + const delta = event.shiftKey ? -1 : 1; + nextIndex = (currentIndex + delta + focusable.length) % focusable.length; + } + + event.preventDefault(); + event.stopPropagation(); + focusable[nextIndex].focus(); + } + + /** + * The focusable elements participating in the focus trap, in DOM order: the + * spotlighted target (when the step advances by pressing it), then any + * interactive content in the (possibly markdown) description, then the visible + * action buttons. Including the target keeps the spotlighted control + * keyboard-reachable, and querying the description keeps markdown links + * reachable despite `aria-modal`. + */ + private _collectFocusable(): HTMLElement[] { + const target = (this._options.advanceOnTargetClick && this._target) ? [this._target] : []; + const descriptionFocusables = Array.from( + // eslint-disable-next-line no-restricted-syntax -- querying our own callout description subtree for focusable markdown content (e.g. links) + this._description.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])') + ); + const buttons = [this._skipButton, this._backButton, this._nextButton] + .filter(button => button.element.style.display !== 'none') + .map(button => button.element); + return [...target, ...descriptionFocusables, ...buttons]; + } + + private _scheduleLayout(): void { + if (this._scheduledLayout) { + return; + } + const targetWindow = getWindow(this._container); + this._scheduledLayout = scheduleAtNextAnimationFrame(targetWindow, () => { + this._scheduledLayout = undefined; + this.layout(); + }); + } + + private _restoreFocus(): void { + const previous = this._previousFocus; + this._previousFocus = undefined; + if (previous && previous.isConnected) { + previous.focus(); + } + } + + override dispose(): void { + this._root.remove(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts new file mode 100644 index 0000000000000..64e28033ee5bf --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from '../../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; +import { IOnboardingPresentation, IOnboardingRunContext } from '../../common/onboardingPresentation.js'; +import { IOnboardingScenario, OnboardingOutcome } from '../../common/onboardingScenario.js'; +import { findOnboardingTarget } from './onboardingTarget.js'; +import { ISpotlightContent, SpotlightOverlay } from './spotlightOverlay.js'; +import { ISpotlightPayload, ISpotlightStep, SPOTLIGHT_PRESENTATION_KIND } from './spotlightTypes.js'; + +/** How long to wait for a step's target element to appear before skipping it. */ +const TARGET_RESOLVE_TIMEOUT = 2000; +const TARGET_POLL_INTERVAL = 50; + +type StepAction = 'next' | 'back' | 'skip' | 'abort'; + +/** + * Renders {@link ISpotlightPayload} scenarios: it dims the window (including the + * native window controls), walks the steps, and shows an anchored callout for + * each. Implements the engine's {@link IOnboardingPresentation} contract so the + * scenario engine can drive it without knowing anything about spotlights. + */ +export class SpotlightPresentation extends Disposable implements IOnboardingPresentation { + + readonly kind = SPOTLIGHT_PRESENTATION_KIND; + + constructor( + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHostService private readonly hostService: IHostService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(); + } + + async run(scenario: IOnboardingScenario, context: IOnboardingRunContext): Promise { + const payload = scenario.presentation.payload as ISpotlightPayload; + const steps = payload?.steps ?? []; + if (steps.length === 0) { + return OnboardingOutcome.Completed; + } + + const store = new DisposableStore(); + try { + const container = this.layoutService.getContainer(context.targetWindow); + const overlay = store.add(new SpotlightOverlay(container)); + + // Dim the native window controls overlay in sync with the dim layer. + this.hostService.setWindowDimmed(context.targetWindow, true); + store.add(toDisposable(() => this.hostService.setWindowDimmed(context.targetWindow, false))); + + let aborted = false; + store.add(context.onAbort(() => { aborted = true; })); + + // Keep the callout glued to the target as the workbench re-layouts. + store.add(this.layoutService.onDidLayoutContainer(() => overlay.layout())); + + let index = 0; + let direction: 1 | -1 = 1; + + while (index >= 0 && index < steps.length && !aborted) { + const step = steps[index]; + + if (step.when && !this.contextKeyService.contextMatchesRules(step.when)) { + index += direction; + continue; + } + + try { + await step.onBeforeShow?.(); + } catch (error) { + onUnexpectedError(error); + } + if (aborted) { + break; + } + + const target = await this._resolveTarget(context.targetWindow, step.targetId); + if (aborted) { + break; + } + if (!target) { + index += direction; + continue; + } + + const action = await this._runStep(overlay, context, step, target, index, steps.length); + switch (action) { + case 'next': + direction = 1; + index++; + break; + case 'back': + direction = -1; + index--; + break; + case 'skip': + return OnboardingOutcome.Skipped; + case 'abort': + return OnboardingOutcome.Aborted; + } + } + + return aborted ? OnboardingOutcome.Aborted : OnboardingOutcome.Completed; + } finally { + store.dispose(); + } + } + + private async _resolveTarget(targetWindow: Window, targetId: string): Promise { + const deadline = Date.now() + TARGET_RESOLVE_TIMEOUT; + let element = findOnboardingTarget(targetWindow, targetId); + while (!element && Date.now() < deadline) { + await timeout(TARGET_POLL_INTERVAL); + element = findOnboardingTarget(targetWindow, targetId); + } + return element; + } + + private _runStep(overlay: SpotlightOverlay, context: IOnboardingRunContext, step: ISpotlightStep, target: HTMLElement, index: number, stepCount: number): Promise { + return new Promise(resolve => { + const stepStore = new DisposableStore(); + const done = (action: StepAction) => { + stepStore.dispose(); + resolve(action); + }; + + stepStore.add(overlay.onDidClickNext(() => done('next'))); + stepStore.add(overlay.onDidClickPrevious(() => done('back'))); + stepStore.add(overlay.onDidSkip(() => done('skip'))); + stepStore.add(context.onAbort(() => done('abort'))); + + const content: ISpotlightContent = { + title: step.title, + description: step.description, + stepIndex: index, + stepCount, + canGoBack: index > 0, + isLastStep: index === stepCount - 1, + }; + + overlay.show(target, content, { + placement: step.placement, + allowTargetInteraction: step.allowTargetInteraction, + advanceOnTargetClick: step.advanceOnTargetClick, + padding: step.padding, + }); + }); + } +} diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts new file mode 100644 index 0000000000000..9c271f4aff5ba --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; + +/** The presentation `kind` handled by the spotlight presentation. */ +export const SPOTLIGHT_PRESENTATION_KIND = 'spotlight'; + +/** Preferred placement of the callout relative to the spotlighted target. */ +export type SpotlightPlacement = 'above' | 'below' | 'left' | 'right' | 'auto'; + +/** + * A single step in a spotlight tour. Steps are pure data; the spotlight + * presentation turns them into the dim overlay, the cut-out highlight and the + * anchored callout. + */ +export interface ISpotlightStep { + /** Stable identifier (unique within the tour). */ + readonly id: string; + + /** + * The `data-onboarding-id` of the element to spotlight. Resolved on demand + * via {@link findOnboardingTarget} so steps work even if the element is not + * yet rendered when the tour starts. + */ + readonly targetId: string; + + /** Callout heading (localized). */ + readonly title: string; + + /** Callout body (localized string or markdown). */ + readonly description: string | IMarkdownString; + + /** Preferred placement of the callout. Defaults to `'auto'`. */ + readonly placement?: SpotlightPlacement; + + /** When present and unsatisfied, the step is skipped. */ + readonly when?: ContextKeyExpression; + + /** Allow the spotlighted element to remain interactive. Defaults to `false`. */ + readonly allowTargetInteraction?: boolean; + + /** + * When set, the step advances when the user clicks the spotlighted target + * itself (rather than a "Next" button). The target is kept interactive. + */ + readonly advanceOnTargetClick?: boolean; + + /** Extra padding (px) around the target when cutting the highlight hole. */ + readonly padding?: number; + + /** + * Optional hook run just before the step is shown, e.g. to open the view + * that hosts the target. Awaited before the target is resolved. + */ + readonly onBeforeShow?: () => Promise | void; +} + +/** + * The payload of a spotlight scenario: an ordered list of steps. + */ +export interface ISpotlightPayload { + readonly steps: readonly ISpotlightStep[]; +} diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingPresentation.ts b/src/vs/workbench/contrib/onboarding/common/onboardingPresentation.ts new file mode 100644 index 0000000000000..5828777ff23d7 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/common/onboardingPresentation.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../../base/common/lifecycle.js'; +import { IOnboardingScenario, OnboardingOutcome } from './onboardingScenario.js'; + +/** + * Context handed to a presentation for a single scenario run. Exposes only what + * a presentation needs without leaking engine internals. + */ +export interface IOnboardingRunContext { + /** The window hosting the UI the scenario targets (usually the main window). */ + readonly targetWindow: Window; + + /** + * Fires when the engine wants the presentation to abort the current run + * (e.g. the application is shutting down). The presentation should resolve + * its `run` promise with {@link OnboardingOutcome.Aborted}. + */ + readonly onAbort: Event; +} + +/** + * A presentation renders a scenario. The engine is presentation-agnostic: it + * looks up a presentation by {@link IOnboardingScenario.presentation} `kind` + * and delegates rendering to it. New onboarding styles (spotlight, banner, + * modal, checklist, ...) are added by registering additional presentations. + */ +export interface IOnboardingPresentation { + /** The kind this presentation handles, e.g. `'spotlight'`. */ + readonly kind: string; + + /** + * Render the scenario and resolve with the outcome once the user (or the + * engine) ends the run. Implementations must clean up all UI/listeners + * regardless of how the run ends. + */ + run(scenario: IOnboardingScenario, context: IOnboardingRunContext): Promise; +} + +/** + * Registry mapping a presentation `kind` to its implementation. + */ +export interface IOnboardingPresentationRegistry { + /** Register a presentation. Throws if the kind is already registered. */ + register(presentation: IOnboardingPresentation): IDisposable; + /** Look up a presentation by kind. */ + get(kind: string): IOnboardingPresentation | undefined; +} + +class OnboardingPresentationRegistry implements IOnboardingPresentationRegistry { + + private readonly _presentations = new Map(); + + register(presentation: IOnboardingPresentation): IDisposable { + const kind = presentation.kind; + if (this._presentations.has(kind)) { + throw new Error(`An onboarding presentation with kind '${kind}' is already registered.`); + } + this._presentations.set(kind, presentation); + return { + dispose: () => { + if (this._presentations.get(kind) === presentation) { + this._presentations.delete(kind); + } + } + }; + } + + get(kind: string): IOnboardingPresentation | undefined { + return this._presentations.get(kind); + } +} + +/** + * Global presentation registry. Presentations register here (typically from the + * workbench contribution), and the engine resolves them when running scenarios. + */ +export const onboardingPresentationRegistry: IOnboardingPresentationRegistry = new OnboardingPresentationRegistry(); diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingRegistry.ts b/src/vs/workbench/contrib/onboarding/common/onboardingRegistry.ts new file mode 100644 index 0000000000000..4bb908580dbb0 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/common/onboardingRegistry.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IOnboardingScenario } from './onboardingScenario.js'; + +/** + * Registry of onboarding scenarios. Feature areas register their scenarios here + * (pure data); the engine reads from it to decide what to run. + */ +export interface IOnboardingScenarioRegistry { + /** Register a scenario. Throws if a scenario with the same id already exists. */ + register(scenario: IOnboardingScenario): IDisposable; + /** All currently registered scenarios. */ + getScenarios(): readonly IOnboardingScenario[]; + /** Look up a single scenario by id. */ + getScenario(id: string): IOnboardingScenario | undefined; + /** Fires when scenarios are added or removed. */ + readonly onDidChange: Event; +} + +class OnboardingScenarioRegistry implements IOnboardingScenarioRegistry { + + private readonly _scenarios = new Map(); + + private readonly _onDidChange = new Emitter(); + readonly onDidChange: Event = this._onDidChange.event; + + register(scenario: IOnboardingScenario): IDisposable { + const id = scenario.id; + if (this._scenarios.has(id)) { + throw new Error(`An onboarding scenario with id '${id}' is already registered.`); + } + this._scenarios.set(id, scenario); + this._onDidChange.fire(); + return { + dispose: () => { + if (this._scenarios.get(id) === scenario) { + this._scenarios.delete(id); + this._onDidChange.fire(); + } + } + }; + } + + getScenarios(): readonly IOnboardingScenario[] { + return Array.from(this._scenarios.values()); + } + + getScenario(id: string): IOnboardingScenario | undefined { + return this._scenarios.get(id); + } +} + +/** + * Global scenario registry. Scenario data files register here on import. + */ +export const onboardingScenarioRegistry: IOnboardingScenarioRegistry = new OnboardingScenarioRegistry(); diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts b/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts new file mode 100644 index 0000000000000..f2a16f12e5196 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; + +/** + * Describes when an onboarding scenario becomes eligible to run automatically. + */ +export type OnboardingTrigger = + /** Eligible as soon as the scenario's `when` clause is satisfied. */ + | { readonly kind: 'auto' } + /** Only started explicitly via a command (never runs automatically). */ + | { readonly kind: 'command'; readonly commandId: string } + /** Eligible whenever the provided observable yields `true`. */ + | { readonly kind: 'observable'; readonly signal: IObservable }; + +/** + * A reference to a registered {@link IOnboardingPresentation} together with the + * opaque payload that presentation understands (e.g. spotlight steps). + */ +export interface IOnboardingPresentationRef { + /** The presentation kind, e.g. `'spotlight'`. */ + readonly kind: string; + /** Presentation-specific data. The engine never inspects this. */ + readonly payload: TPayload; +} + +/** + * A declarative onboarding scenario. Scenarios are pure data: they describe + * *when* onboarding should happen and *which* presentation renders it, but not + * *how* it is rendered (that is the presentation's job). + */ +export interface IOnboardingScenario { + /** Stable identifier. Used as the persistence key for "shown once" state. */ + readonly id: string; + + /** Eligibility gate. AND-ed with the engine's own checks. */ + readonly when?: ContextKeyExpression; + + /** How the scenario becomes eligible to run automatically. */ + readonly trigger: OnboardingTrigger; + + /** Higher priority scenarios run first when several are eligible at once. Default `0`. */ + readonly priority?: number; + + /** The presentation that renders this scenario plus its payload. */ + readonly presentation: IOnboardingPresentationRef; + + /** Optional experiment treatment id that must be enabled for the scenario to run. */ + readonly experiment?: string; + + /** When `true`, the scenario runs on every eligible session instead of once per user. */ + readonly repeatable?: boolean; +} + +/** + * The result of running a scenario's presentation. + */ +export const enum OnboardingOutcome { + /** The user reached the end of the scenario. */ + Completed = 'completed', + /** The user explicitly skipped the scenario. */ + Skipped = 'skipped', + /** The scenario was dismissed (e.g. closed without an explicit skip). */ + Dismissed = 'dismissed', + /** The scenario was aborted by the engine (e.g. window shutdown, target lost). */ + Aborted = 'aborted' +} diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts b/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts new file mode 100644 index 0000000000000..3a7ddb5f187ff --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IOnboardingScenario, OnboardingOutcome } from './onboardingScenario.js'; + +export const IOnboardingScenarioService = createDecorator('onboardingScenarioService'); + +/** Global setting that enables/disables *automatic* onboarding scenarios. */ +export const ONBOARDING_ENABLED_CONFIG = 'onboarding.enabled'; + +/** + * Developer/advanced setting. When enabled, onboarding tours bypass usage-based + * eligibility gating (e.g. the new-session tour's "first few sessions" check) so + * they can be triggered on demand for testing. Tours are still shown at most + * once — use the "Reset Onboarding Shown State" command to replay them. + */ +export const ONBOARDING_DEVELOPER_MODE_CONFIG = 'onboarding.developerMode'; + +/** + * The presentation-agnostic onboarding engine. It decides *when* and *whether* + * a scenario runs (eligibility, scheduling, once-per-user persistence) and + * delegates *how* it is rendered to a registered presentation. + */ +export interface IOnboardingScenarioService { + readonly _serviceBrand: undefined; + + /** + * Begin watching scenario eligibility. Automatic scenarios may start as soon + * as they become eligible. Safe to call multiple times (no-op after first). + */ + start(): void; + + /** + * Run a scenario on demand, bypassing the once-per-user gate and the global + * `onboarding.enabled` setting. Used by command triggers and the (future) + * tutorial page. Still serialized with any in-flight scenario. + */ + runScenario(id: string): Promise; + + /** + * All registered scenarios (across presentation kinds). Useful for a tutorial + * page that lists everything the user can replay. + */ + getScenarios(): readonly IOnboardingScenario[]; + + /** Whether the scenario has already been shown to the user. */ + hasBeenShown(id: string): boolean; + + /** Clear the "shown" state for a single scenario (developer/testing aid). */ + reset(id: string): void; + + /** Clear the "shown" state for all scenarios (developer/testing aid). */ + resetAll(): void; +} diff --git a/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts new file mode 100644 index 0000000000000..0941382062b25 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { InMemoryStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { Memento } from '../../../../common/memento.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { NullWorkbenchAssignmentService } from '../../../../services/assignment/test/common/nullAssignmentService.js'; +import { TestLifecycleService } from '../../../../test/common/workbenchTestServices.js'; +import { OnboardingScenarioService } from '../../browser/onboardingService.js'; +import { IOnboardingPresentation, IOnboardingRunContext, onboardingPresentationRegistry } from '../../common/onboardingPresentation.js'; +import { onboardingScenarioRegistry } from '../../common/onboardingRegistry.js'; +import { IOnboardingScenario, OnboardingOutcome } from '../../common/onboardingScenario.js'; +import { ONBOARDING_ENABLED_CONFIG } from '../../common/onboardingScenarioService.js'; + +/** Records every scenario it renders, then resolves with a fixed outcome. */ +class RecordingPresentation implements IOnboardingPresentation { + readonly runs: string[] = []; + constructor( + readonly kind: string, + private readonly outcome: OnboardingOutcome = OnboardingOutcome.Completed, + private readonly onRun?: () => void, + ) { } + async run(scenario: IOnboardingScenario, _context: IOnboardingRunContext): Promise { + this.runs.push(scenario.id); + this.onRun?.(); + return this.outcome; + } +} + +/** Blocks until the engine aborts the run (used to test shutdown behaviour). */ +class BlockingUntilAbortPresentation implements IOnboardingPresentation { + readonly runs: string[] = []; + constructor(readonly kind: string) { } + run(scenario: IOnboardingScenario, context: IOnboardingRunContext): Promise { + this.runs.push(scenario.id); + return new Promise(resolve => { + const listener = context.onAbort(() => { + listener.dispose(); + resolve(OnboardingOutcome.Aborted); + }); + }); + } +} + +suite('OnboardingScenarioService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + teardown(() => { + // The Memento maintains a static cache keyed by id; clear it so each test + // starts with fresh persisted state instead of leaking across tests. + Memento.clear(StorageScope.PROFILE); + }); + + let idSeed = 0; + function uniqueKind(): string { return `test-presentation-${idSeed++}`; } + + function createService(configValues: Record = {}, assignment?: IWorkbenchAssignmentService): { service: OnboardingScenarioService; contextKeyService: IContextKeyService; config: TestConfigurationService; lifecycle: TestLifecycleService } { + const store = disposables; + const storage = store.add(new InMemoryStorageService()); + const config = new TestConfigurationService(configValues); + const contextKeyService = store.add(new ContextKeyService(config)); + const lifecycle = store.add(new TestLifecycleService()); + const service = store.add(new OnboardingScenarioService( + storage, + contextKeyService, + config as unknown as IConfigurationService, + lifecycle, + assignment ?? new NullWorkbenchAssignmentService(), + )); + return { service, contextKeyService, config, lifecycle }; + } + + function registerPresentation(presentation: IOnboardingPresentation): void { + disposables.add(onboardingPresentationRegistry.register(presentation)); + } + + function registerScenario(scenario: IOnboardingScenario): void { + disposables.add(onboardingScenarioRegistry.register(scenario)); + } + + test('runs an eligible auto scenario exactly once and marks it shown', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + registerScenario({ id: 'auto-1', trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService(); + service.start(); + await timeout(0); + + // Re-evaluating (e.g. another start) must not run it again. + service.start(); + await timeout(0); + + assert.deepStrictEqual( + { runs: presentation.runs, shown: service.hasBeenShown('auto-1') }, + { runs: ['auto-1'], shown: true } + ); + }); + + test('does not run automatically when onboarding.enabled is false', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + registerScenario({ id: 'disabled-1', trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService({ [ONBOARDING_ENABLED_CONFIG]: false }); + service.start(); + await timeout(0); + + assert.deepStrictEqual(presentation.runs, []); + }); + + test('respects the when clause and reacts to context changes', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + registerScenario({ + id: 'when-1', + when: ContextKeyExpr.equals('onboardingTestReady', true), + trigger: { kind: 'auto' }, + presentation: { kind: presentation.kind, payload: undefined } + }); + + const { service, contextKeyService } = createService(); + service.start(); + await timeout(0); + assert.deepStrictEqual(presentation.runs, [], 'should not run while when is unsatisfied'); + + const key: IContextKey = contextKeyService.createKey('onboardingTestReady', false); + key.set(true); + await timeout(0); + + assert.deepStrictEqual(presentation.runs, ['when-1']); + }); + + test('runs higher-priority scenarios before lower-priority ones', async () => { + const order: string[] = []; + const presentation = new RecordingPresentation(uniqueKind(), OnboardingOutcome.Completed); + // Track ordering via the recorder's runs array which is appended in run(). + registerPresentation(presentation); + registerScenario({ id: 'low', priority: 1, trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + registerScenario({ id: 'high', priority: 10, trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService(); + service.start(); + await timeout(0); + order.push(...presentation.runs); + + assert.deepStrictEqual(order, ['high', 'low']); + }); + + test('observable triggers start the scenario when the signal turns true', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + const signal = observableValue('onboardingTestSignal', false); + registerScenario({ id: 'observable-1', trigger: { kind: 'observable', signal }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService(); + service.start(); + await timeout(0); + assert.deepStrictEqual(presentation.runs, [], 'should not run while signal is false'); + + signal.set(true, undefined); + await timeout(0); + + assert.deepStrictEqual(presentation.runs, ['observable-1']); + }); + + test('command-triggered scenarios never run automatically', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + registerScenario({ id: 'command-1', trigger: { kind: 'command', commandId: 'noop' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService(); + service.start(); + await timeout(0); + + assert.deepStrictEqual(presentation.runs, []); + }); + + test('runScenario runs manually even when disabled and already shown', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + registerScenario({ id: 'manual-1', trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService({ [ONBOARDING_ENABLED_CONFIG]: false }); + service.start(); + await timeout(0); + assert.deepStrictEqual(presentation.runs, [], 'disabled: should not auto-run'); + + const outcome = await service.runScenario('manual-1'); + + assert.deepStrictEqual({ runs: presentation.runs, outcome }, { runs: ['manual-1'], outcome: OnboardingOutcome.Completed }); + }); + + test('runScenario joins an in-flight run instead of starting a second one', async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const kind = uniqueKind(); + const runs: string[] = []; + const presentation: IOnboardingPresentation = { + kind, + async run(scenario: IOnboardingScenario): Promise { + runs.push(scenario.id); + await gate; + return OnboardingOutcome.Completed; + } + }; + registerPresentation(presentation); + registerScenario({ id: 'inflight-1', trigger: { kind: 'command', commandId: 'noop' }, presentation: { kind, payload: undefined } }); + + const { service } = createService(); + service.start(); + + const first = service.runScenario('inflight-1'); + await timeout(0); + // Second call while the first run is still in-flight must not start again. + const second = service.runScenario('inflight-1'); + await timeout(0); + + release(); + const [a, b] = await Promise.all([first, second]); + + assert.deepStrictEqual({ runs, a, b }, { runs: ['inflight-1'], a: OnboardingOutcome.Completed, b: OnboardingOutcome.Completed }); + }); + + test('resetAll clears shown state so the scenario can run again', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + registerScenario({ id: 'reset-1', trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService(); + service.start(); + await timeout(0); + + service.resetAll(); + assert.strictEqual(service.hasBeenShown('reset-1'), false); + }); + + test('experiment gate blocks the scenario until the treatment is enabled', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + + class DisabledExperimentAssignmentService extends NullWorkbenchAssignmentService { + override async getTreatment(_name: string): Promise { + return undefined; + } + } + + registerScenario({ id: 'exp-off', experiment: 'exp.disabled', trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService({}, new DisabledExperimentAssignmentService()); + service.start(); + await timeout(0); + + assert.deepStrictEqual(presentation.runs, []); + }); + + test('experiment gate allows the scenario once the treatment resolves true', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + + class EnabledExperimentAssignmentService extends NullWorkbenchAssignmentService { + override async getTreatment(_name: string): Promise { + return true as T; + } + } + + registerScenario({ id: 'exp-on', experiment: 'exp.enabled', trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const { service } = createService({}, new EnabledExperimentAssignmentService()); + service.start(); + // Allow the async treatment fetch + re-evaluation to settle. + await timeout(0); + await timeout(0); + + assert.deepStrictEqual(presentation.runs, ['exp-on']); + }); + + test('shutdown aborts the active scenario and never starts queued ones', async () => { + const active = new BlockingUntilAbortPresentation(uniqueKind()); + const queued = new RecordingPresentation(uniqueKind()); + registerPresentation(active); + registerPresentation(queued); + // `active` has higher priority so it runs first and blocks; `queued` waits. + registerScenario({ id: 'active', priority: 10, trigger: { kind: 'auto' }, presentation: { kind: active.kind, payload: undefined } }); + registerScenario({ id: 'queued', priority: 1, trigger: { kind: 'auto' }, presentation: { kind: queued.kind, payload: undefined } }); + + const { service, lifecycle } = createService(); + service.start(); + await timeout(0); + // Only the active (blocking) scenario should have started. + assert.deepStrictEqual({ active: active.runs, queued: queued.runs }, { active: ['active'], queued: [] }); + + lifecycle.fireShutdown(); + await timeout(0); + + // The queued scenario must never have been presented. + assert.deepStrictEqual({ active: active.runs, queued: queued.runs }, { active: ['active'], queued: [] }); + }); + + test('service starts and disposes without leaking', () => { + const store = new DisposableStore(); + const storage = store.add(new InMemoryStorageService()); + const config = new TestConfigurationService(); + const contextKeyService = store.add(new ContextKeyService(config)); + const lifecycle = store.add(new TestLifecycleService()); + const service = store.add(new OnboardingScenarioService(storage, contextKeyService, config as unknown as IConfigurationService, lifecycle, new NullWorkbenchAssignmentService())); + service.start(); + store.dispose(); + assert.ok(true); + }); +}); diff --git a/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts new file mode 100644 index 0000000000000..d75c52285780f --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { $ } from '../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ISpotlightContent, SpotlightOverlay } from '../../browser/spotlight/spotlightOverlay.js'; + +/** Minimal fake ResizeObserver so the widget can be tested without real layout churn. */ +class FakeResizeObserver implements ResizeObserver { + readonly observed: Element[] = []; + constructor(private readonly _callback: ResizeObserverCallback) { } + observe(target: Element): void { this.observed.push(target); } + unobserve(): void { } + disconnect(): void { } + trigger(): void { this._callback([], this); } +} + +suite('SpotlightOverlay', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContainer(): HTMLElement { + const container = $('div.test-spotlight-container'); + mainWindow.document.body.appendChild(container); + disposables.add({ dispose: () => container.remove() }); + return container; + } + + function createTarget(container: HTMLElement, left: number, top: number, width: number, height: number): HTMLElement { + const target = $('div.test-spotlight-target'); + target.style.position = 'fixed'; + target.style.left = `${left}px`; + target.style.top = `${top}px`; + target.style.width = `${width}px`; + target.style.height = `${height}px`; + container.appendChild(target); + return target; + } + + function content(overrides: Partial = {}): ISpotlightContent { + return { + title: 'My Title', + description: 'My description', + stepIndex: 1, + stepCount: 3, + canGoBack: true, + isLastStep: false, + ...overrides + }; + } + + function getButtons(container: HTMLElement): HTMLElement[] { + return Array.from(container.getElementsByClassName('monaco-button')) as HTMLElement[]; + } + + test('show renders content and positions the hole around the target (+padding)', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 100, 50, 200, 40); + + overlay.show(target, content()); + + const root = container.getElementsByClassName('spotlight-overlay')[0] as HTMLElement; + const hole = container.getElementsByClassName('spotlight-hole')[0] as HTMLElement; + + assert.deepStrictEqual({ + visible: root.style.display !== 'none', + title: container.getElementsByClassName('spotlight-callout-title')[0].textContent, + description: container.getElementsByClassName('spotlight-callout-description')[0].textContent, + counter: container.getElementsByClassName('spotlight-callout-counter')[0].textContent, + holeLeft: hole.style.left, + holeTop: hole.style.top, + holeWidth: hole.style.width, + holeHeight: hole.style.height, + }, { + visible: true, + title: 'My Title', + description: 'My description', + counter: '2 of 3', + holeLeft: '94px', + holeTop: '44px', + holeWidth: '212px', + holeHeight: '52px', + }); + }); + + test('Next / Back / Skip buttons fire the corresponding events', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 0, 0, 50, 50); + + const fired: string[] = []; + disposables.add(overlay.onDidSkip(() => fired.push('skip'))); + disposables.add(overlay.onDidClickPrevious(() => fired.push('back'))); + disposables.add(overlay.onDidClickNext(() => fired.push('next'))); + + overlay.show(target, content()); + + // Buttons are appended in order: Skip, Back, Next. + const [skip, back, next] = getButtons(container); + skip.click(); + back.click(); + next.click(); + + assert.deepStrictEqual(fired, ['skip', 'back', 'next']); + }); + + test('advanceOnTargetClick hides Next and advances when the target is clicked', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 100, 100, 80, 30); + + let advanced = 0; + disposables.add(overlay.onDidClickNext(() => advanced++)); + + overlay.show(target, content(), { advanceOnTargetClick: true }); + + const [, , next] = getButtons(container); + const blocker = container.getElementsByClassName('spotlight-blocker')[0] as HTMLElement; + target.click(); + + assert.deepStrictEqual({ + nextHidden: next.style.display === 'none', + interactive: blocker.style.clipPath.includes('evenodd'), + advanced + }, { nextHidden: true, interactive: true, advanced: 1 }); + }); + + test('Back button is hidden when canGoBack is false and Next becomes Done on the last step', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 0, 0, 50, 50); + + overlay.show(target, content({ canGoBack: false, isLastStep: true })); + + const [, back, next] = getButtons(container); + assert.deepStrictEqual({ backHidden: back.style.display === 'none', nextLabel: next.textContent }, { backHidden: true, nextLabel: 'Done' }); + }); + + test('allowTargetInteraction cuts a hole out of the click blocker', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 100, 100, 80, 30); + const blocker = () => container.getElementsByClassName('spotlight-blocker')[0] as HTMLElement; + + overlay.show(target, content(), { allowTargetInteraction: false }); + assert.strictEqual(blocker().style.clipPath, ''); + + overlay.show(target, content(), { allowTargetInteraction: true }); + assert.ok(blocker().style.clipPath.includes('evenodd'), 'expected an evenodd clip-path'); + }); + + test('observes the target and container for re-layout', () => { + const container = createContainer(); + const observers: FakeResizeObserver[] = []; + const ctor = class extends FakeResizeObserver { + constructor(cb: ResizeObserverCallback) { super(cb); observers.push(this); } + }; + const overlay = disposables.add(new SpotlightOverlay(container, ctor as unknown as typeof ResizeObserver)); + const target = createTarget(container, 0, 0, 50, 50); + + overlay.show(target, content()); + + assert.deepStrictEqual(observers.length === 1 ? observers[0].observed.includes(target) && observers[0].observed.includes(container) : false, true); + }); + + test('focus trap includes links rendered in a markdown description', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 0, 0, 50, 50); + + overlay.show(target, content({ description: new MarkdownString('See [the docs](https://example.com) for more.') })); + + const callout = container.getElementsByClassName('spotlight-callout')[0] as HTMLElement; + const link = callout.getElementsByTagName('a')[0] as HTMLAnchorElement | undefined; + assert.ok(link, 'expected a link to be rendered from the markdown description'); + + // With the link focused, Shift+Tab should cycle to the last button (Next), + // proving the link participates in the trap rather than being skipped. + link!.focus(); + const event = new KeyboardEvent('keydown', { shiftKey: true, bubbles: true, cancelable: true }); + // The KeyboardEvent constructor does not honor `keyCode` from the init dict + // in all engines, so set it explicitly (StandardKeyboardEvent reads keyCode). + Object.defineProperty(event, 'keyCode', { get: () => 9 /* Tab */ }); + callout.dispatchEvent(event); + + assert.strictEqual(mainWindow.document.activeElement, getButtons(container).at(-1)); + }); + + test('dispose removes the overlay from the DOM', () => { + const container = createContainer(); + const overlay = new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver); + const target = createTarget(container, 0, 0, 50, 50); + overlay.show(target, content()); + + overlay.dispose(); + + assert.strictEqual(container.getElementsByClassName('spotlight-overlay').length, 0); + }); +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index b6c33f809d1fc..176480be5b83d 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -392,6 +392,9 @@ import './contrib/welcomeViews/common/newFile.contribution.js'; // Welcome Onboarding import './contrib/welcomeOnboarding/browser/welcomeOnboarding.contribution.js'; +// Onboarding (scenario engine) +import './contrib/onboarding/browser/onboarding.contribution.js'; + // Call Hierarchy import './contrib/callHierarchy/browser/callHierarchy.contribution.js'; From 1770f89f360fdffad4102c294dbcdbe4c4265e34 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 23 Jun 2026 13:54:23 +0100 Subject: [PATCH 022/696] Refactor style overrides and add editor border (#322532) * refactor: unify style override class naming and introduce editor border styles - Changed all style override rules to be gated behind a single `.style-override` class instead of individual classes for each module. - Updated CSS files for keyboard focus, padding, pane headers, rounded corners, scroll shadows, status bar, tabs, and added a new editor border style. - The editor border style adds a subtle border around the editor area, enhancing visual separation from surrounding UI elements. Co-authored-by: Copilot * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: mrleemurray Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../browser/media/activityBar.css | 14 +-- .../browser/media/commandCenter.css | 12 +-- .../browser/media/editorBorder.css | 40 +++++++++ .../styleOverrides/browser/media/fontRamp.css | 54 ++++++------ .../browser/media/keyboardFocusOnly.css | 24 ++--- .../styleOverrides/browser/media/padding.css | 14 +-- .../browser/media/paneHeaders.css | 26 +++--- .../browser/media/roundedCorners.css | 88 +++++++++---------- .../browser/media/scrollShadows.css | 34 +++---- .../browser/media/statusBar.css | 16 ++-- .../styleOverrides/browser/media/tabs.css | 62 ++++++------- .../browser/styleOverrides.contribution.ts | 39 ++++---- 12 files changed, 235 insertions(+), 188 deletions(-) create mode 100644 src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css index 3ff1a9e2b385d..d5908b50faf19 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css @@ -9,7 +9,7 @@ */ /* Drop the 2px left border indicator on the active item. */ -.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator:before { +.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator:before { border-left: none !important; } @@ -21,7 +21,7 @@ * (36px wide / 32px tall) layouts. The square side tracks the action height * (the limiting dimension), and `left` keeps it horizontally centered. */ -.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { +.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { z-index: 0; top: 0; left: calc((var(--activity-bar-width, 48px) - var(--activity-bar-action-height, 48px) + 8px) / 2); @@ -43,19 +43,19 @@ * mirror the same tint there, scoped to `:not(.codicon)` so codicon items never * gain a filled background box. */ -.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label.codicon { +.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label.codicon { color: var(--vscode-foreground) !important; } -.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked) .action-label.codicon { +.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked) .action-label.codicon { color: var(--vscode-descriptionForeground) !important; } -.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label:not(.codicon) { +.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label:not(.codicon) { background-color: var(--vscode-foreground) !important; } -.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked) .action-label:not(.codicon) { +.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked) .action-label:not(.codicon) { background-color: var(--vscode-descriptionForeground) !important; } @@ -71,7 +71,7 @@ * for the drop-line indicators) and on the checked item (which already shows the * active box). */ -.style-override-activityBar .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover::before { +.style-override .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover::before { content: ""; display: block; position: absolute; diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css index 8695ade49d1da..1a6bc0258f689 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css @@ -13,7 +13,7 @@ * rest and only reveal the command-center active background/border on hover, * instead of carrying a solid fill the whole time. * - * All rules are gated behind the `.style-override-commandCenter` class, which + * All rules are gated behind the `.style-override` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ @@ -23,7 +23,7 @@ * transparent at rest; the base stylesheet's `:hover` rule (titlebarpart.css) * still reveals the active background/border on hover. */ -.style-override-commandCenter.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { +.style-override.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { background-color: transparent !important; } @@ -33,8 +33,8 @@ * `:hover` rules already reveal the active background, and the connected * `session-mode` pill keeps its active fill (excluded below). */ -.style-override-commandCenter .agent-status-pill:not(.session-mode), -.style-override-commandCenter .agent-status-pill .agent-status-input-area { +.style-override .agent-status-pill:not(.session-mode), +.style-override .agent-status-pill .agent-status-input-area { background-color: transparent !important; } @@ -45,7 +45,7 @@ * rule still reveals the active background, and the `filtered` state keeps its * own active fill (excluded below). */ -.style-override-commandCenter .agent-status-badge-section:not(.filtered), -.style-override-commandCenter .agent-status-command-center-toolbar { +.style-override .agent-status-badge-section:not(.filtered), +.style-override .agent-status-command-center-toolbar { background-color: transparent !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css b/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css new file mode 100644 index 0000000000000..80e8227c4d393 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Editor Border". + * + * Frames the editor area as a distinct card by drawing a subtle 1px border with + * rounded corners around the whole editor part (tabs, breadcrumbs and editor + * body). This separates the editor from the surrounding chrome (side bar, + * panel, auxiliary bar) and mirrors the card-like editor treatment used in the + * Agents window (src/vs/sessions/browser/media/style.css). + * + * The border is drawn with `box-sizing: border-box` so it sits inside the area + * the grid already allocates to the editor part — no margin is added, avoiding a + * relayout. `overflow: hidden` clips the editor content to the rounded corners. + * The floating editor window part (`.modal-editor-part`) is excluded. + * + * All rules are gated behind the `.style-override` class, which the + * StyleOverridesContribution toggles onto the workbench container(s) when the + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. + * Without that class these rules never apply. + */ + +.style-override .monaco-grid-view .part.editor:not(.modal-editor-part) { + border: var(--vscode-strokeThickness) solid var(--vscode-editorGroup-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)); + border-radius: var(--vscode-cornerRadius-medium, 6px); + box-sizing: border-box; + overflow: hidden; +} + +/* + * The editor part paints a separator shadow on its leading edge (via the + * `::after` overlay) to set itself apart from the side bar. With the explicit + * border above that shadow is redundant and would double up, so suppress it. + */ +.style-override .part.editor:not(.modal-editor-part)::after { + box-shadow: none; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css index 208e73835ed0b..89cc2999b023d 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css @@ -23,7 +23,7 @@ * across the large surface area that already consumes them. Headings, section * titles / tabs and badges are then mapped onto their canonical surfaces. * - * All rules are gated behind the `.style-override-fontRamp` class, which the + * All rules are gated behind the `.style-override` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. Without * that class these rules never apply. @@ -36,14 +36,14 @@ * the workbench from a single place. The tiers map as: bodyFontSize = Body 1 * (13px), small = Label 1 (12px), xSmall = Body 2 / Label 2 (11px). */ -.style-override-fontRamp { +.style-override { --vscode-bodyFontSize: 13px; --vscode-bodyFontSize-small: 12px; --vscode-bodyFontSize-xSmall: 11px; } /* Anchor the workbench base font on Body 1 so inheriting surfaces follow the ramp. */ -.style-override-fontRamp.monaco-workbench { +.style-override.monaco-workbench { font-size: var(--vscode-bodyFontSize); } @@ -52,14 +52,14 @@ * ========================================================================== */ /* Heading 1 (26px) — welcome / getting started screen title. */ -.style-override-fontRamp.monaco-workbench .part.editor > .content .gettingStartedContainer h1 { +.style-override.monaco-workbench .part.editor > .content .gettingStartedContainer h1 { font-size: 26px; font-weight: 600; } /* Heading 2 (18px) — welcome subtitle and secondary screen titles. */ -.style-override-fontRamp.monaco-workbench .part.editor > .content .gettingStartedContainer .subtitle, -.style-override-fontRamp.monaco-workbench .part.editor > .content .gettingStartedContainer h2 { +.style-override.monaco-workbench .part.editor > .content .gettingStartedContainer .subtitle, +.style-override.monaco-workbench .part.editor > .content .gettingStartedContainer h2 { font-size: 18px; font-weight: 600; } @@ -69,13 +69,13 @@ * ========================================================================== */ /* View pane (sidebar / panel section) header titles — section title, Strong. */ -.style-override-fontRamp .monaco-pane-view .pane > .pane-header .title { +.style-override .monaco-pane-view .pane > .pane-header .title { font-size: var(--vscode-bodyFontSize-small); font-weight: 600; } /* Part (sidebar / auxiliary bar / panel) title labels — section title, Strong. */ -.style-override-fontRamp.monaco-workbench .part > .title > .title-label h2 { +.style-override.monaco-workbench .part > .title > .title-label h2 { font-size: var(--vscode-bodyFontSize-small); font-weight: 600; } @@ -85,12 +85,12 @@ * like the part title-labels so it matches the primary side bar section header * instead of looking like a lighter composite tab. */ -.style-override-fontRamp.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { +.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { font-weight: 600; } /* Editor tab labels. */ -.style-override-fontRamp.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label { +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label { font-size: var(--vscode-bodyFontSize-small); } @@ -98,8 +98,8 @@ * Label 3 (10px) — badges and counters. * ========================================================================== */ -.style-override-fontRamp .monaco-count-badge, -.style-override-fontRamp .monaco-action-bar .badge .badge-content { +.style-override .monaco-count-badge, +.style-override .monaco-action-bar .badge .badge-content { font-size: 10px; } @@ -107,13 +107,13 @@ * Casing — replace ALL-CAPS labels with capitalized (title-case) text. The * underlying strings are already properly cased, so these surfaces only need * their `text-transform: uppercase` swapped for `capitalize`. The extra - * `.style-override-fontRamp` ancestor raises specificity over the base rules. + * `.style-override` ancestor raises specificity over the base rules. * ========================================================================== */ /* View pane (sidebar / panel section) header titles. */ -.style-override-fontRamp .monaco-pane-view .pane > .pane-header > .title, +.style-override .monaco-pane-view .pane > .pane-header > .title, /* Part titles (sidebar, auxiliary bar, panel) — but never editor filenames. */ -.style-override-fontRamp.monaco-workbench .part:not(.editor) > .title > .title-label h2, +.style-override.monaco-workbench .part:not(.editor) > .title > .title-label h2, /* * Panel / composite bar action-item labels. The font-size must land on the * `.action-label` (the ``), not the `.action-item` (the `
  • `): the base @@ -121,17 +121,17 @@ * directly on the label, and a direct value always beats one inherited from the * parent item, so targeting the item alone leaves the visible text at 11px. */ -.style-override-fontRamp.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.style-override-fontRamp.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, /* Notifications center header. */ -.style-override-fontRamp.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title, +.style-override.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title, /* Debug call-stack session state labels. */ -.style-override-fontRamp .debug-pane .debug-call-stack .session > .state.label, +.style-override .debug-pane .debug-call-stack .session > .state.label, /* Settings editor tab labels and section headers. */ -.style-override-fontRamp .settings-tabs-widget > .monaco-action-bar .action-item .action-label, -.style-override-fontRamp .monaco-editor .settings-header-widget .title-container .title, +.style-override .settings-tabs-widget > .monaco-action-bar .action-item .action-label, +.style-override .monaco-editor .settings-header-widget .title-container .title, /* Agent sessions list header ("SESSIONS"). */ -.style-override-fontRamp .chat-viewpane.has-sessions-control .agent-sessions-container .agent-sessions-title-container { +.style-override .chat-viewpane.has-sessions-control .agent-sessions-container .agent-sessions-title-container { text-transform: capitalize; font-size: var(--vscode-bodyFontSize-small); } @@ -143,12 +143,12 @@ * tab. The base rules (auxiliaryBarPart.css) paint the indicator with * `!important` from a `.part.auxiliarybar` selector, so the override must mirror * those exact selectors (including the `:not(:focus)` / `.clicked:focus` states) - * and rely on the extra `.style-override-fontRamp` class to win on specificity. + * and rely on the extra `.style-override` class to win on specificity. */ -.style-override-fontRamp.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, -.style-override-fontRamp.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before, -.style-override-fontRamp.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, -.style-override-fontRamp.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { +.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before, +.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { border-top-color: transparent !important; border-bottom-color: transparent !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css index ff78c1cc2dd38..b887f6d7cac8f 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css @@ -13,7 +13,7 @@ * outlines on `:focus:not(:focus-visible)` therefore removes mouse-click focus * rings and leaves keyboard focus rings intact. * - * All rules are gated behind the `.style-override-keyboardFocusOnly` class, which + * All rules are gated behind the `.style-override` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * @@ -27,9 +27,9 @@ * `:focus` but not `:focus-visible`, so drop the row ring in that case. Keyboard * focus (`:focus-visible`) keeps it. */ -.style-override-keyboardFocusOnly .part.sidebar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, -.style-override-keyboardFocusOnly .part.panel .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, -.style-override-keyboardFocusOnly .part.auxiliarybar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused { +.style-override .part.sidebar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, +.style-override .part.panel .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, +.style-override .part.auxiliarybar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused { outline: 0 !important; } @@ -40,13 +40,13 @@ * keyboard focus. The part element is listed explicitly because a descendant * combinator would not match the part itself. */ -.style-override-keyboardFocusOnly .part.sidebar:focus:not(:focus-visible), -.style-override-keyboardFocusOnly .part.sidebar :focus:not(:focus-visible), -.style-override-keyboardFocusOnly .part.panel:focus:not(:focus-visible), -.style-override-keyboardFocusOnly .part.panel :focus:not(:focus-visible), -.style-override-keyboardFocusOnly .part.auxiliarybar:focus:not(:focus-visible), -.style-override-keyboardFocusOnly .part.auxiliarybar :focus:not(:focus-visible), -.style-override-keyboardFocusOnly .part.statusbar:focus:not(:focus-visible), -.style-override-keyboardFocusOnly .part.statusbar :focus:not(:focus-visible) { +.style-override .part.sidebar:focus:not(:focus-visible), +.style-override .part.sidebar :focus:not(:focus-visible), +.style-override .part.panel:focus:not(:focus-visible), +.style-override .part.panel :focus:not(:focus-visible), +.style-override .part.auxiliarybar:focus:not(:focus-visible), +.style-override .part.auxiliarybar :focus:not(:focus-visible), +.style-override .part.statusbar:focus:not(:focus-visible), +.style-override .part.statusbar :focus:not(:focus-visible) { outline-color: transparent !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css index 2e1ef09a34291..3543288867d3e 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css @@ -9,7 +9,7 @@ * Adjusts the internal padding of common workbench surfaces to give content * more breathing room. * - * All rules are gated behind the `.style-override-padding` class, which the + * All rules are gated behind the `.style-override` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. @@ -32,20 +32,24 @@ */ /* Header: inset the section title / actions to line up with the rows below. */ -.style-override-padding .monaco-pane-view .pane:not(:has(> .pane-body.chat-viewpane)) > .pane-header { +.style-override .monaco-pane-view .pane:not(:has(> .pane-body.chat-viewpane)) > .pane-header { padding-left: var(--vscode-spacing-size80, 8px); padding-right: var(--vscode-spacing-size80, 8px); } /* Body: inset each row box, leaving the scrollbar pinned to the pane edge. */ -.style-override-padding .monaco-pane-view .pane-body:not(.chat-viewpane) .monaco-list-row { +.style-override .monaco-pane-view .pane-body:not(.chat-viewpane) .monaco-list-row { left: var(--vscode-spacing-size80, 8px); right: var(--vscode-spacing-size80, 8px); width: auto; } /* Tighten the part title bar gutters: flush-left, small right inset. */ -.style-override-padding.monaco-workbench .part > .title { +.style-override.monaco-workbench .part > .title { padding-left: var(--vscode-spacing-size40, 4px); - padding-right: var(--vscode-spacing-size80, 8px); + padding-right: var(--vscode-spacing-size40, 4px); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions { + padding: 0 4px; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css index da82c6d158d1e..e9acb2e41c96a 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css @@ -15,7 +15,7 @@ * inline styles by the PaneView (src/vs/base/browser/ui/splitview/paneview.ts), * so the resets below must use `!important` to win over those inline values. * - * All rules are gated behind the `.style-override-paneHeaders` class, which the + * All rules are gated behind the `.style-override` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. @@ -25,8 +25,8 @@ * Remove the full-width separator lines / borders (set inline by the PaneView) * so we can redraw them inset below. */ -.style-override-paneHeaders .monaco-pane-view .pane > .pane-header, -.style-override-paneHeaders .monaco-pane-view .pane { +.style-override .monaco-pane-view .pane > .pane-header, +.style-override .monaco-pane-view .pane { border: none !important; } @@ -36,7 +36,7 @@ * header border color the PaneView would, falling back across sidebar / panel * tokens so it is visible in both locations. */ -.style-override-paneHeaders .monaco-pane-view .pane > .pane-header::before { +.style-override .monaco-pane-view .pane > .pane-header::before { content: ""; position: absolute; top: 0; @@ -48,13 +48,13 @@ } /* The first section in a pane has no separator above it. */ -.style-override-paneHeaders .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header::before { +.style-override .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header::before { display: none; } -.style-override-paneHeaders .monaco-pane-view .pane, -.style-override-paneHeaders.floating-panels .part.sidebar, -.style-override-paneHeaders.floating-panels .part.auxiliarybar { +.style-override .monaco-pane-view .pane, +.style-override.floating-panels .part.sidebar, +.style-override.floating-panels .part.auxiliarybar { background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; } @@ -62,7 +62,7 @@ * reads as part of the panel / side bar body rather than a tinted strip. The * inline header background from the PaneView is overridden here; the hover tint * below still wins via its higher specificity. */ -.style-override-paneHeaders .monaco-pane-view .pane > .pane-header { +.style-override .monaco-pane-view .pane > .pane-header { border-radius: var(--vscode-cornerRadius-medium); background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; } @@ -75,12 +75,12 @@ * StyleOverridesContribution triggers a relayout when this setting changes so * the new reservation is picked up immediately. */ -.style-override-paneHeaders .monaco-pane-view { +.style-override .monaco-pane-view { --pane-header-size: 28px; } /* Tint the header background on hover (overrides the inline header background). */ -.style-override-paneHeaders .monaco-pane-view .pane > .pane-header:hover { +.style-override .monaco-pane-view .pane > .pane-header:hover { background-color: var(--vscode-list-hoverBackground) !important; } @@ -89,7 +89,7 @@ * outline comes from the global `.monaco-workbench [tabindex="0"]:focus` rule. * `:not(:focus-visible)` keeps the visible focus ring for keyboard users. */ -.style-override-paneHeaders .monaco-pane-view .pane > .pane-header:focus:not(:focus-visible) { +.style-override .monaco-pane-view .pane > .pane-header:focus:not(:focus-visible) { outline: none !important; } @@ -98,6 +98,6 @@ * stroke right on the 1px section separator so it looks clipped. Inset it a bit * more so the keyboard focus ring clears the separator. */ -.style-override-paneHeaders .monaco-pane-view .pane > .pane-header:focus { +.style-override .monaco-pane-view .pane > .pane-header:focus { outline-offset: -2px; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css index 47e756af0f834..b6483c9c0b3e5 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css @@ -17,7 +17,7 @@ * - Controls (4px): all interactable UI controls (text inputs, selects, * list/tree rows, ...). * - * All rules are gated behind the `.style-override-roundedCorners` class, which + * All rules are gated behind the `.style-override` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. @@ -30,7 +30,7 @@ * as: small = controls (4px), medium = inner containers (6px), large = outer / * floating overlays (8px). */ -.style-override-roundedCorners { +.style-override { --vscode-cornerRadius-xSmall: 4px; --vscode-cornerRadius-small: 4px; --vscode-cornerRadius-medium: 6px; @@ -43,30 +43,30 @@ * ========================================================================== */ /* Text inputs and find inputs */ -.style-override-roundedCorners .monaco-inputbox { +.style-override .monaco-inputbox { border-radius: var(--vscode-cornerRadius-small) !important; } -.style-override-roundedCorners .monaco-findInput, -.style-override-roundedCorners .monaco-findInput .monaco-inputbox { +.style-override .monaco-findInput, +.style-override .monaco-findInput .monaco-inputbox { border-radius: var(--vscode-cornerRadius-small) !important; } /* Select / dropdown controls */ -.style-override-roundedCorners .monaco-select-box, -.style-override-roundedCorners .monaco-select-box-dropdown-container { +.style-override .monaco-select-box, +.style-override .monaco-select-box-dropdown-container { border-radius: var(--vscode-cornerRadius-small) !important; } /* List / tree rows (including the notification center list rows) */ -.style-override-roundedCorners .monaco-list .monaco-list-row, -.style-override-roundedCorners .notifications-list-container .monaco-list-row { +.style-override .monaco-list .monaco-list-row, +.style-override .notifications-list-container .monaco-list-row { border-radius: var(--vscode-cornerRadius-small) !important; } /* Action bar labels / keybindings (toolbar icon buttons) */ -.style-override-roundedCorners .monaco-action-bar .action-label, -.style-override-roundedCorners .monaco-action-bar .action-item .keybinding { +.style-override .monaco-action-bar .action-label, +.style-override .monaco-action-bar .action-item .keybinding { border-radius: var(--vscode-cornerRadius-small) !important; } @@ -78,36 +78,36 @@ */ /* All-corners segments */ -.style-override-roundedCorners .agent-status-pill, -.style-override-roundedCorners .agent-status-badge, -.style-override-roundedCorners .agent-status-badge-section:only-child, -.style-override-roundedCorners .agent-status-esc-button, -.style-override-roundedCorners .agent-status-enter-button { +.style-override .agent-status-pill, +.style-override .agent-status-badge, +.style-override .agent-status-badge-section:only-child, +.style-override .agent-status-esc-button, +.style-override .agent-status-enter-button { border-radius: var(--vscode-cornerRadius-small) !important; } /* Leading-edge segments (rounded left, flat right) */ -.style-override-roundedCorners .agent-status-pill .agent-status-input-area, -.style-override-roundedCorners .agent-status-command-center-toolbar, -.style-override-roundedCorners .agent-status-badge-section:first-child, -.style-override-roundedCorners .agent-status-badge-section.sparkle .action-container { +.style-override .agent-status-pill .agent-status-input-area, +.style-override .agent-status-command-center-toolbar, +.style-override .agent-status-badge-section:first-child, +.style-override .agent-status-badge-section.sparkle .action-container { border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small) !important; } /* Trailing-edge segments (flat left, rounded right) */ -.style-override-roundedCorners .agent-status-badge-section:last-child, -.style-override-roundedCorners .agent-status-badge-section.sparkle:last-child .dropdown-action-container { +.style-override .agent-status-badge-section:last-child, +.style-override .agent-status-badge-section.sparkle:last-child .dropdown-action-container { border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 !important; } /* Intentional flat seams where a segment butts up against its neighbour */ -.style-override-roundedCorners .agent-status-line-separator + .agent-status-input-area, -.style-override-roundedCorners .agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { +.style-override .agent-status-line-separator + .agent-status-input-area, +.style-override .agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { border-radius: 0 !important; } /* Scrollbar sliders */ -.style-override-roundedCorners .monaco-scrollable-element > .scrollbar > .slider { +.style-override .monaco-scrollable-element > .scrollbar > .slider { border-radius: var(--vscode-cornerRadius-small) !important; } @@ -115,7 +115,7 @@ * The editor's own scrollbars sit flush against the viewport edge (next to the * minimap); rounding their sliders looks odd, so keep those square. */ -.style-override-roundedCorners .monaco-editor .monaco-scrollable-element > .scrollbar > .slider { +.style-override .monaco-editor .monaco-scrollable-element > .scrollbar > .slider { border-radius: 0 !important; } @@ -124,7 +124,7 @@ * ========================================================================== */ /* Editor sticky scroll container */ -.style-override-roundedCorners .monaco-editor .sticky-widget { +.style-override .monaco-editor .sticky-widget { border-radius: var(--vscode-cornerRadius-medium) !important; overflow: hidden; } @@ -134,7 +134,7 @@ * (and the focused row draws an outline) on this element, so round it to the * inner tier to match. */ -.style-override-roundedCorners .settings-editor .settings-tree-container .setting-item-contents.settings-row-inner-container { +.style-override .settings-editor .settings-tree-container .setting-item-contents.settings-row-inner-container { border-radius: var(--vscode-cornerRadius-medium) !important; } @@ -143,21 +143,21 @@ * ========================================================================== */ /* Quick input (command palette, quick open) */ -.style-override-roundedCorners .quick-input-widget { +.style-override .quick-input-widget { border-radius: var(--vscode-cornerRadius-large) !important; overflow: hidden; } /* Editor / workbench hover, suggest and parameter hint widgets */ -.style-override-roundedCorners .monaco-hover, -.style-override-roundedCorners .editor-widget.suggest-widget, -.style-override-roundedCorners .monaco-editor .parameter-hints-widget { +.style-override .monaco-hover, +.style-override .editor-widget.suggest-widget, +.style-override .monaco-editor .parameter-hints-widget { border-radius: var(--vscode-cornerRadius-large) !important; overflow: hidden; } /* Context menus and general menu surfaces */ -.style-override-roundedCorners .monaco-menu .monaco-action-bar.vertical { +.style-override .monaco-menu .monaco-action-bar.vertical { border-radius: var(--vscode-cornerRadius-large) !important; } @@ -166,7 +166,7 @@ * children with `overflow: hidden`, so rounding the container alone gives the * header its top corners and the list its bottom corners in one place. */ -.style-override-roundedCorners .notifications-center { +.style-override .notifications-center { border-radius: var(--vscode-cornerRadius-large) !important; } @@ -178,32 +178,32 @@ * radius on the outer wrapper leaves a gap at the corner where the smaller * inner radius shows through. */ -.style-override-roundedCorners .notifications-toasts .notifications-list-container, -.style-override-roundedCorners .notifications-toasts .notification-toast-container > .notification-toast, -.style-override-roundedCorners .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, -.style-override-roundedCorners .notifications-toasts .notification-toast-container > .notification-toast .monaco-list-row { +.style-override .notifications-toasts .notifications-list-container, +.style-override .notifications-toasts .notification-toast-container > .notification-toast, +.style-override .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, +.style-override .notifications-toasts .notification-toast-container > .notification-toast .monaco-list-row { border-radius: var(--vscode-cornerRadius-large) !important; } /* Modal dialogs */ -.style-override-roundedCorners .monaco-dialog-box { +.style-override .monaco-dialog-box { border-radius: var(--vscode-cornerRadius-large) !important; overflow: hidden; } /* Editor find / replace widget */ -.style-override-roundedCorners .monaco-editor .find-widget { +.style-override .monaco-editor .find-widget { border-radius: var(--vscode-cornerRadius-large) !important; } /* Rename input widget */ -.style-override-roundedCorners .monaco-editor .rename-box { +.style-override .monaco-editor .rename-box { border-radius: var(--vscode-cornerRadius-large) !important; overflow: hidden; } /* Color picker widget */ -.style-override-roundedCorners .colorpicker-widget { +.style-override .colorpicker-widget { border-radius: var(--vscode-cornerRadius-large) !important; overflow: hidden; } @@ -211,11 +211,11 @@ /* ============================================================================= * Sashes — round the hover/drag affordance ends (mirrors the Agents window). * ========================================================================== */ -.style-override-roundedCorners .monaco-sash:before { +.style-override .monaco-sash:before { border-radius: calc(var(--vscode-sash-hover-size) / 2); } -.style-override-roundedCorners .monaco-sash:not(.disabled) > .orthogonal-drag-handle { +.style-override .monaco-sash:not(.disabled) > .orthogonal-drag-handle { border-radius: var(--vscode-sash-size); } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css index 0a96b9dabb0b2..69573363f6377 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css @@ -20,7 +20,7 @@ * variable, defaulted below and refined per part. The overlays are * non-interactive and sit above the scrolling content. * - * All rules are gated behind the `.style-override-scrollShadows` class, which + * All rules are gated behind the `.style-override` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ @@ -29,31 +29,31 @@ * Surface colour the edges fade into. Default to the editor background, then * refine per part so lists in the panel / side bar fade into their own surface. */ -.style-override-scrollShadows { +.style-override { --scroll-shadow-surface: var(--vscode-editor-background); } -.style-override-scrollShadows .part.panel { +.style-override .part.panel { --scroll-shadow-surface: var(--vscode-panel-background); } -.style-override-scrollShadows .part.sidebar { +.style-override .part.sidebar { --scroll-shadow-surface: var(--vscode-sideBar-background, var(--vscode-editor-background)); } -.style-override-scrollShadows .part.auxiliarybar { +.style-override .part.auxiliarybar { --scroll-shadow-surface: var(--vscode-sideBar-background, var(--vscode-editor-background)); } /* ============================================================================= * Lists and trees. * ========================================================================== */ -.style-override-scrollShadows .monaco-list { +.style-override .monaco-list { position: relative; } -.style-override-scrollShadows .monaco-list > .monaco-scrollable-element::before, -.style-override-scrollShadows .monaco-list > .monaco-scrollable-element::after { +.style-override .monaco-list > .monaco-scrollable-element::before, +.style-override .monaco-list > .monaco-scrollable-element::after { content: ""; position: absolute; left: 0; @@ -69,26 +69,26 @@ * stacking context and sit below it. Scope to the editor / panel parts so the * fade never leaks onto inline editors such as the chat input. * ========================================================================== */ -.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::before, -.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::after { +.style-override .part.editor .monaco-editor .editor-scrollable::before, +.style-override .part.editor .monaco-editor .editor-scrollable::after { content: ""; position: absolute; left: 0; right: 0; - height: 24px; + height: 8px; pointer-events: none; /* Below the scrollbar (z-index 11) so it is never obscured, above content. */ z-index: 10; } -.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::before, -.style-override-scrollShadows .part.panel .monaco-editor .editor-scrollable::before { +.style-override .part.editor .monaco-editor .editor-scrollable::before, +.style-override .part.panel .monaco-editor .editor-scrollable::before { top: 0; background: linear-gradient(to bottom, var(--scroll-shadow-surface), transparent); } -.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::after, -.style-override-scrollShadows .part.panel .monaco-editor .editor-scrollable::after { +.style-override .part.editor .monaco-editor .editor-scrollable::after, +.style-override .part.panel .monaco-editor .editor-scrollable::after { bottom: 0; background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); } @@ -99,7 +99,7 @@ * inputs, not as scrolling editor surfaces. Suppress the edge fades there so the * input keeps a flat background and never shows the editor-coloured rectangle. * ========================================================================== */ -.style-override-scrollShadows .suggest-input-container .monaco-editor .editor-scrollable::before, -.style-override-scrollShadows .suggest-input-container .monaco-editor .editor-scrollable::after { +.style-override .suggest-input-container .monaco-editor .editor-scrollable::before, +.style-override .suggest-input-container .monaco-editor .editor-scrollable::after { display: none; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css index 8f4cd0ba7a7c4..59b99f121b81c 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css @@ -13,7 +13,7 @@ * editor foreground so it stays readable across all themes. Items that set * their own color (e.g. prominent, error or warning entries) keep theirs. */ -.style-override-statusBar .part.statusbar { +.style-override .part.statusbar { color: var(--vscode-foreground) !important; } @@ -26,7 +26,7 @@ * color — `--vscode-foreground` for standard items, or the kind color for * warning/error items — so only the background changes on hover. */ -.style-override-statusBar .part.statusbar > .items-container > .statusbar-item > a:hover:not(.disabled) { +.style-override .part.statusbar > .items-container > .statusbar-item > a:hover:not(.disabled) { color: inherit !important; } @@ -43,19 +43,19 @@ * the left (square left, round right), `.compact-right` joins on the right (square * right, round left), and a middle item stays square on both sides. */ -.style-override-statusBar .part.statusbar > .items-container > .statusbar-item, -.style-override-statusBar .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { +.style-override .part.statusbar > .items-container > .statusbar-item, +.style-override .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { border-radius: var(--vscode-cornerRadius-small, 4px); } -.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-left, -.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { +.style-override .part.statusbar > .items-container > .statusbar-item.compact-left, +.style-override .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { border-top-left-radius: 0; border-bottom-left-radius: 0; } -.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-right, -.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { +.style-override .part.statusbar > .items-container > .statusbar-item.compact-right, +.style-override .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { border-top-right-radius: 0; border-bottom-right-radius: 0; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 711927464d02d..7d30875c46fe2 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -9,19 +9,19 @@ * Makes the editor tabs in the regular workbench look like the Agents window * tabs: transparent, rounded, borderless pills with a subtle active highlight. * - * All rules are gated behind the `.style-override-tabs` class, which the + * All rules are gated behind the `.style-override` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. Mirrors * the Agents window styling defined in * src/vs/sessions/browser/media/style.css. */ -.style-override-tabs .part.editor .title.tabs { +.style-override .part.editor .title.tabs { background-color: transparent !important; --editor-group-tab-height: 26px !important; } -.style-override-tabs .part.editor .tabs-container > .tab { +.style-override .part.editor .tabs-container > .tab { background-color: color-mix(in srgb, var(--vscode-foreground) 5%, transparent) !important; border-right: none !important; border-radius: var(--vscode-cornerRadius-small); @@ -34,22 +34,22 @@ } /* Center tabs vertically and strip the bottom border so the pills float. */ -.style-override-tabs .part.editor .tabs-and-actions-container { +.style-override .part.editor .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; align-items: center; - padding: 4px 0; + padding: 4px 0 4px 4px; } -.style-override-tabs .part.editor .tabs-container > .tab.active { +.style-override .part.editor .tabs-container > .tab.active { background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; } -.style-override-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):hover { +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):hover { background-color: color-mix(in srgb, var(--vscode-foreground) 8%, transparent) !important; } -.style-override-tabs .part.editor .tabs-container > .tab .tab-border-top-container, -.style-override-tabs .part.editor .tabs-container > .tab .tab-border-bottom-container { +.style-override .part.editor .tabs-container > .tab .tab-border-top-container, +.style-override .part.editor .tabs-container > .tab .tab-border-bottom-container { display: none !important; } @@ -60,7 +60,7 @@ * the editor tabs: normal case, 12px/600 type, a subtle background on the * active tab and no underline. */ -.style-override-tabs .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { +.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { text-transform: none !important; padding: 0 8px; border-radius: var(--vscode-cornerRadius-small); @@ -72,24 +72,24 @@ * does not reach the label (an explicit font-size on the element wins over the * inherited value), so the type must be set on the label itself. */ -.style-override-tabs .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { +.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { font-size: 12px; font-weight: var(--vscode-agents-fontWeight-semiBold); line-height: 22px; /* keep consistent with other 22px title/control heights */ } -.style-override-tabs .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { +.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; } /* * Drop the underline active indicator in favour of the pill background. The * default rules set the border with `!important`, so match their specificity - * (with the extra `.style-override-tabs` class winning) for the checked and + * (with the extra `.style-override` class winning) for the checked and * focused states. */ -.style-override-tabs .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, -.style-override-tabs .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { +.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, +.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { border-top-color: transparent !important; border-top-width: 0 !important; border-bottom-color: transparent !important; @@ -104,8 +104,8 @@ * them the rounded-pill look: normal case, 12px/600 type, a subtle background * on the active tab and no underline indicator. */ -.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, -.style-override-tabs .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { +.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, +.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { text-transform: none !important; padding: 0 8px; border-radius: var(--vscode-cornerRadius-small); @@ -115,15 +115,15 @@ * The text lives in `.action-label`, which carries its own `font-size: 11px` * from the base action bar styles, so the type must be set on the label itself. */ -.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.style-override-tabs .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { +.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { font-size: 12px; font-weight: var(--vscode-agents-fontWeight-semiBold); line-height: 22px; /* keep consistent with other 22px title/control heights */ } -.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked, -.style-override-tabs .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { +.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked, +.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; } @@ -131,20 +131,20 @@ * Drop the underline active indicator in favour of the pill background. The * base rules (auxiliaryBarPart.css / paneCompositePart.css) paint the indicator * with `!important` from `.part.auxiliarybar` selectors, so match their - * specificity (with the extra `.style-override-tabs` class winning) across the + * specificity (with the extra `.style-override` class winning) across the * `.title` and `.header-or-footer` checked and focused states. */ -.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, -.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before, -.style-override-tabs .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, -.style-override-tabs .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { +.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, +.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before, +.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, +.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { border-top-color: transparent !important; border-top-width: 0 !important; border-bottom-color: transparent !important; border-bottom-width: 0 !important; } -.style-override-tabs .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label a { +.style-override .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label a { font-size: 12px; font-weight: var(--vscode-agents-fontWeight-semiBold); } @@ -156,22 +156,22 @@ * pill look as the editor and panel tabs: drop the underline, round the label * and show a subtle background on the active (checked) tab. */ -.style-override-tabs .settings-editor .settings-tabs-widget > .monaco-action-bar .action-item .action-label { +.style-override .settings-editor .settings-tabs-widget > .monaco-action-bar .action-item .action-label { border-radius: var(--vscode-cornerRadius-small) !important; border-bottom: none !important; font-weight: var(--vscode-agents-fontWeight-semiBold); padding: 2px 8px !important; } -.style-override-tabs .settings-editor .settings-tabs-widget > .monaco-action-bar .action-item .action-label.checked { +.style-override .settings-editor .settings-tabs-widget > .monaco-action-bar .action-item .action-label.checked { background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; } -.style-override-tabs .settings-editor .settings-tabs-widget > .monaco-action-bar .action-item .action-label.checked:not(:focus) { +.style-override .settings-editor .settings-tabs-widget > .monaco-action-bar .action-item .action-label.checked:not(:focus) { border-bottom-color: transparent !important; } /* Drop the underline beneath the settings header controls row. */ -.style-override-tabs .settings-editor > .settings-header > .settings-header-controls { +.style-override .settings-editor > .settings-header > .settings-header-controls { border-bottom: none !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts index f26669cd628f9..c2c773c405ce5 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts +++ b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts @@ -10,11 +10,12 @@ import { IWorkbenchLayoutService, LayoutSettings } from '../../../services/layou import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -// Bundle the CSS for every style-override module. Each file gates all of its -// rules behind a `.style-override-` ancestor class, so the styles are inert -// until the matching class is toggled onto the workbench container(s) below. +// Bundle the CSS for every style-override module. Every file gates all of its +// rules behind the single `.style-override` ancestor class, so the styles are +// inert until that class is toggled onto the workbench container(s) below. import './media/activityBar.css'; import './media/commandCenter.css'; +import './media/editorBorder.css'; import './media/fontRamp.css'; import './media/keyboardFocusOnly.css'; import './media/padding.css'; @@ -34,15 +35,26 @@ interface IStyleOverrideModule { readonly layoutAffecting?: boolean; } +/** + * The single class toggled onto the workbench container(s) when the Modern UI + * Update experiment is enabled. Every style-override module's CSS is gated + * behind this class (`.style-override ...`), so all modules are applied together + * as a group. + */ +const STYLE_OVERRIDE_CLASS = 'style-override'; + /** * The fixed catalog of built-in style-override modules. The CSS for each module - * ships with the product (imported above) and is gated behind the - * `.style-override-` class. All modules are enabled together as part of the - * Modern UI Update experiment (`LayoutSettings.MODERN_UI`). + * ships with the product (imported above) and is gated behind the shared + * `.style-override` 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). */ const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ { id: 'activityBar' }, { id: 'commandCenter' }, + { id: 'editorBorder' }, { id: 'fontRamp' }, { id: 'keyboardFocusOnly' }, { id: 'padding' }, @@ -53,10 +65,6 @@ const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ { id: 'tabs' } ]; -function classNameFor(moduleId: string): string { - return `style-override-${moduleId}`; -} - /** * A contribution that toggles the built-in CSS style-override modules on or off * as a group, based on the `workbench.experimental.modernUI` setting. When the @@ -67,7 +75,6 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench static readonly ID = 'workbench.contrib.styleOverrides'; - private readonly knownClassNames = STYLE_OVERRIDE_MODULES.map(m => classNameFor(m.id)); private readonly hasLayoutAffectingModule = STYLE_OVERRIDE_MODULES.some(m => m.layoutAffecting); /** Whether a layout-affecting module was active at the last applied selection. */ @@ -128,17 +135,13 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench } private applyTo(container: HTMLElement, enabled: boolean): void { - for (const className of this.knownClassNames) { - container.classList.toggle(className, enabled); - } + container.classList.toggle(STYLE_OVERRIDE_CLASS, enabled); } override dispose(): void { - // Remove any classes this contribution added so it leaves no DOM state behind. + // Remove the class this contribution added so it leaves no DOM state behind. for (const container of this.layoutService.containers) { - for (const className of this.knownClassNames) { - container.classList.remove(className); - } + container.classList.remove(STYLE_OVERRIDE_CLASS); } super.dispose(); } From c50b851a9cf6b32feaed4c756ac5494dd6cdc647 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Tue, 23 Jun 2026 14:56:55 +0200 Subject: [PATCH 023/696] [TEMPORARY] smoke: loop Codex session test 20x to probe flakiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DO NOT MERGE — revert before merging PR #322366. Adds a temporary "Codex smoke flakiness probe" step to the Electron-Smoke job of all three PR test workflows (Linux/macOS/Windows). Each runs the `Test Codex session` smoke test 20 times in a row from source (where the node_modules SDK fallback applies) and fails fast on the first failure, preserving that run's traces/screenshots. This verifies the Codex dev path is not flaky across platforms. Remove these three steps before merge — they add ~1h per platform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-darwin-test.yml | 23 +++++++++++++---------- .github/workflows/pr-linux-test.yml | 15 +++++++++++++++ .github/workflows/pr-win32-test.yml | 15 +++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index 971fda8191f1e..914ece49b1cea 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -192,21 +192,24 @@ jobs: timeout-minutes: 20 run: npm run smoketest-no-compile -- --tracing + # TEMPORARY: loop the Codex smoke test to confirm it is not flaky. + # Remove this step (and the matching ones in pr-linux-test.yml / + # pr-win32-test.yml) before merging PR #322366. + - name: 🧪 Codex smoke flakiness probe (TEMPORARY) + if: ${{ inputs.electron_tests && inputs.smoke_tests }} + timeout-minutes: 90 + run: | + for i in $(seq 1 20); do + echo "::group::Codex smoke probe run $i/20" + npm run smoketest-no-compile -- --tracing -g "Test Codex session" || { echo "::error::Codex smoke test failed on run $i/20"; exit 1; } + echo "::endgroup::" + done + - name: 🧪 Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --web --tracing --headless - - name: 🧪 Run smoke tests (Remote) - if: ${{ inputs.remote_tests && inputs.smoke_tests }} - timeout-minutes: 20 - run: npm run smoketest-no-compile -- --remote --tracing - - - name: Diagnostics after smoke test run - if: ${{ inputs.smoke_tests && always() }} - run: ps -ef - continue-on-error: true - - name: Publish Crash Reports uses: actions/upload-artifact@v7 if: failure() diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 113d41cdda3bb..bb00b2b44e7ae 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -372,6 +372,21 @@ jobs: env: DISPLAY: ":10" + # TEMPORARY: loop the Codex smoke test to confirm it is not flaky. + # Remove this step (and the matching ones in pr-darwin-test.yml / + # pr-win32-test.yml) before merging PR #322366. + - name: 🧪 Codex smoke flakiness probe (TEMPORARY) + if: ${{ inputs.electron_tests && inputs.smoke_tests }} + timeout-minutes: 90 + run: | + for i in $(seq 1 20); do + echo "::group::Codex smoke probe run $i/20" + npm run smoketest-no-compile -- --tracing -g "Test Codex session" || { echo "::error::Codex smoke test failed on run $i/20"; exit 1; } + echo "::endgroup::" + done + env: + DISPLAY: ":10" + - name: 🧪 Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index 45681aff7077b..a56b15e98d11b 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -224,6 +224,21 @@ jobs: shell: pwsh run: npm run smoketest-no-compile -- --tracing + # TEMPORARY: loop the Codex smoke test to confirm it is not flaky. + # Remove this step (and the matching ones in pr-darwin-test.yml / + # pr-linux-test.yml) before merging PR #322366. + - name: 🧪 Codex smoke flakiness probe (TEMPORARY) + if: ${{ inputs.electron_tests && inputs.smoke_tests }} + timeout-minutes: 90 + shell: pwsh + run: | + for ($i = 1; $i -le 20; $i++) { + Write-Host "::group::Codex smoke probe run $i/20" + npm run smoketest-no-compile -- --tracing -g "Test Codex session" + if ($LASTEXITCODE -ne 0) { Write-Host "::error::Codex smoke test failed on run $i/20"; exit 1 } + Write-Host "::endgroup::" + } + - name: 🧪 Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 From b2dd4c84d709081ce138439d7526276590888ba5 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 23 Jun 2026 23:37:00 +1000 Subject: [PATCH 024/696] chat: enhance draft input restoration logic to use session history model (#322514) --- .../common/chatService/chatServiceImpl.ts | 14 +++++- .../common/chatService/chatService.test.ts | 47 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 0b21f9b8a7267..1228ecc883ffe 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -622,11 +622,13 @@ export class ChatService extends Disposable implements IChatService { const storedInputState = storedMetadata?.inputState; let initialData: ISerializedChatDataReference | undefined = undefined; let historySelectedModel: string | undefined = undefined; + let historyDerivedModel: ISerializableChatModelInputState['selectedModel'] = undefined; if ((modelId || agentUri)) { const mode: ISerializableChatModelInputState['mode'] = agentUri ? { kind: ChatModeKind.Agent, id: agentUri.toString() } : { kind: ChatModeKind.Agent, id: ChatMode.Agent.id }; const modelMetadata = modelId ? this.languageModelsService.lookupLanguageModel(modelId) : undefined; const selectedModel: ISerializableChatModelInputState['selectedModel'] = modelId && modelMetadata ? { identifier: modelId, metadata: modelMetadata } : undefined; historySelectedModel = selectedModel?.identifier; + historyDerivedModel = selectedModel; // This is used to initialize the state of the chat input box, with the selected model, mode, etc initialData = { serializer: new ChatSessionOperationLog(), @@ -654,15 +656,23 @@ export class ChatService extends Disposable implements IChatService { } // Contributed sessions do not use UI tools. - // Prefer (in order): a transferred draft, a persisted draft from metadata, + // Prefer (in order): a transferred draft, the persisted draft from metadata, // otherwise let the constructor fall back to initialData.value.inputState. + // When restoring the persisted draft we keep the unsent text/selections/mode but + // deliberately drop its persisted selectedModel (it can be stale or belong to a + // different model pool) in favour of the model derived from the session's request + // history. When no history model is available the model is left undefined so the + // input part resolves it via its own selection logic. + const restoredDraft: ISerializableChatModelInputState | undefined = storedInputState + ? { ...storedInputState, selectedModel: historyDerivedModel } + : undefined; const modelRef = this._sessionModels.acquireOrCreate({ initialData, location, sessionResource: sessionResource, canUseTools: false, transferEditingSession: providedSession.transferredState?.editingSession, - inputState: providedSession.transferredState?.inputState, + inputState: providedSession.transferredState?.inputState ?? restoredDraft, }, debugOwner ?? 'ChatService#loadRemoteSession'); logChangesToStateModel(modelRef.object.inputModel, `loadRemoteSession inputState source: session=${sessionResource.toString()}, chatSessionType=${chatSessionType}, historyModelId=${modelId}, agentUri=${agentUri?.toString()}, historySelectedModel=${historySelectedModel}, transferredSelectedModel=${providedSession.transferredState?.inputState?.selectedModel?.identifier}, storedSelectedModel=${storedInputState?.selectedModel?.identifier}, finalSelectedModel=${modelRef.object.inputModel.state.get()?.selectedModel?.identifier}, hasTransferredInputState=${!!providedSession.transferredState?.inputState}, hasStoredInputState=${!!storedInputState}, hasInitialData=${!!initialData}`, modelRef.object.inputModel.state.get(), undefined, this.logService); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 0e6070c3dd3db..478515ed23038 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -20,6 +20,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -48,7 +49,7 @@ import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReferenc import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; -import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatModel, IChatModel, IChatRequestVariableData, ISerializableChatData } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -1790,7 +1791,7 @@ suite('ChatService', () => { assert.strictEqual(model.lastRequest?.response?.isComplete, true, 'Non-streaming session should complete response at load time'); }); - test.skip('draft input is restored after disposing and reloading a remote session', async () => { + test('draft input is restored after disposing and reloading a remote session', async () => { const { resource } = setupRemoteProvider({ history: [] }); const testService = createChatService(); @@ -1816,6 +1817,48 @@ suite('ChatService', () => { const restored = model2.inputModel.state.get(); assert.strictEqual(restored?.inputText, 'unsent draft', 'Input text should be restored'); }); + + test('restored draft uses the session history model, not the persisted (stale) one', async () => { + const historyModelId = 'history-model'; + const historyMetadata: ILanguageModelChatMetadata = { + id: historyModelId, name: 'History Model', vendor: 'copilot', version: '1.0', family: 'history', + extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, + isDefaultForLocation: { [ChatAgentLocation.Chat]: true } + }; + // Resolve only the model the session actually used in its history. + instantiationService.stub(ILanguageModelsService, new class extends NullLanguageModelsService { + override lookupLanguageModel(id: string): ILanguageModelChatMetadata | undefined { + return id === historyModelId ? historyMetadata : undefined; + } + }); + + const { resource } = setupRemoteProvider({ + history: [{ type: 'request', prompt: 'hello', participant: remoteScheme, modelId: historyModelId }] + }); + + const testService = createChatService(); + + // Load, then seed an unsent draft AND a stale model selection that must NOT survive reload. + const ref1 = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref1, 'Should load remote session'); + (ref1.object as ChatModel).inputModel.setState({ + inputText: 'unsent draft', + selectedModel: { identifier: 'stale-model', metadata: { ...historyMetadata, id: 'stale-model', name: 'Stale Model' } }, + }); + + ref1.dispose(); + await testService.waitForModelDisposals(); + + const ref2 = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref2, 'Should re-load remote session'); + testDisposables.add(ref2); + const restored = (ref2.object as ChatModel).inputModel.state.get(); + assert.deepStrictEqual( + { inputText: restored?.inputText, selectedModel: restored?.selectedModel?.identifier }, + { inputText: 'unsent draft', selectedModel: historyModelId }, + 'Draft text is restored and the model comes from session history, not the stale persisted selection' + ); + }); }); test('onWillSaveState persists session index synchronously so it survives reload', async () => { From 6577439bdf06a2f673ac376ada5bb569f3d9129d Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:37:41 +0200 Subject: [PATCH 025/696] Reenable 2nd turn of Copilot CLI test (#322056) * Reenable 2nd turn of Copilot CLI test * test running on azure dev ops * Restore title change guard * Run even if failure * Try fix missing chat session * Try to prevent session from getting disposed * Try to fix another flake * Remove flake probe --- .../sessions/contrib/chat/browser/chatView.ts | 9 +++ .../api/browser/mainThreadChatSessions.ts | 44 +++++++++++++- test/automation/src/agentsWindow.ts | 34 +++++++++++ .../areas/agentsWindow/agentsWindow.test.ts | 59 ++++++++++++------- test/smoke/src/areas/chat/copilotCli.test.ts | 2 +- test/smoke/src/utils.ts | 26 +++++++- 6 files changed, 149 insertions(+), 25 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index d1fcacf235df9..3fe4667c177d3 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -211,6 +211,11 @@ export class ChatView extends AbstractChatView { this._modelRef.value = ref; this._updateWidgetLockState(getChatSessionType(ref.object.sessionResource)); this._widget.setModel(ref.object); + // Expose the bound chat resource on the DOM so test automation + // can synchronize with the post-rebind state without polling timeouts. + // Set AFTER `setModel` so observers see the attribute only once the + // inner widget is fully attached to the loaded model. + this.element.dataset.boundChatResource = resource.toString(); }, err => { if (!token.isCancellationRequested) { this.logService.error('[ChatView] Failed to load chat model for chat', err); @@ -230,6 +235,10 @@ export class ChatView extends AbstractChatView { this._widget.clear().catch(err => this.logService.error('[ChatView] Failed to clear chat widget', err)); this._widget.setModel(undefined); this._modelRef.clear(); + // Clear the bound-resource attribute while the rebind is in flight so + // test automation can wait for the next `setChat` cycle to finish + // before acting on the view. + delete this.element.dataset.boundChatResource; } private _applyHistoryKey(): void { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 025cd437eff01..766469be047ce 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -76,6 +76,19 @@ export class ObservableChatSession extends Disposable implements IChatSession { private _interruptionWasCanceled = false; private _disposalPending = false; + /** + * Number of currently in-flight `requestHandler` invocations. Used to + * defer `$disposeChatSessionContent` when the workbench wants to release + * this session while a request is mid-flight on a `requestHandler`-style + * session (e.g. Copilot CLI). Without this guard, the proxy dispose + * synchronously cancels the ext-host `disposeCts` and tears down the + * underlying SDK session (which calls `sdkSession.abort()`), so the + * in-flight request is lost. The `activeResponseCallback`-style sessions + * have their own deferral via {@link interruptActiveResponseCallback}; + * this counter is the equivalent for `requestHandler`-style sessions. + */ + private _inFlightRequestCount = 0; + private _initializationPromise?: Promise; interruptActiveResponseCallback?: () => Promise; @@ -215,6 +228,7 @@ export class ObservableChatSession extends Disposable implements IChatSession { history: any[], token: CancellationToken ) => { + this._inFlightRequestCount++; // Clear previous progress and mark as active this._progressObservable.set([], undefined); this._isCompleteObservable.set(false, undefined); @@ -256,6 +270,15 @@ export class ObservableChatSession extends Disposable implements IChatSession { } finally { // Ensure progress observation is cleaned up progressDisposable.dispose(); + this._inFlightRequestCount--; + // If a dispose was requested while this request was in flight, + // fire the proxy disposal now that the last request has settled. + // Guarded by `!this.interruptActiveResponseCallback` so we don't + // trample the existing interruption-confirmation flow. + if (this._disposalPending && this._inFlightRequestCount === 0 && !this.interruptActiveResponseCallback) { + this._disposalPending = false; + this._proxy.$disposeChatSessionContent(this._providerHandle, this.sessionResource); + } } }; } @@ -358,6 +381,12 @@ export class ObservableChatSession extends Disposable implements IChatSession { if (this.interruptActiveResponseCallback && !this._interruptionWasCanceled) { this._disposalPending = true; // The actual disposal will happen in the interruption callback based on user's choice + } else if (this._inFlightRequestCount > 0) { + // Defense in depth for `requestHandler`-style sessions (e.g. Copilot CLI): + // defer the ext-host disposal until any in-flight request settles so the + // SDK session isn't aborted mid-request. The deferred call fires from + // the `requestHandler`'s `finally` block when the counter reaches zero. + this._disposalPending = true; } else { // No active response callback or user already canceled interruption - dispose immediately this._proxy.$disposeChatSessionContent(this._providerHandle, this.sessionResource); @@ -869,11 +898,22 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat const chatViewWidget = this._chatWidgetService.getWidgetBySessionResource(originalResource); if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) { await this._chatWidgetService.openSession(modifiedResource, undefined, { preserveFocus: true }); - } else { - // Loading the session to ensure the session is created and editing session is transferred. + } else if (!chatViewWidget) { + // No widget currently shows the original session — eagerly load the + // session so the transferred state set above is materialized into a + // chat model. We immediately release the reference; if a consumer + // later acquires the session, the model will be re-created. const ref = await this._chatService.acquireOrLoadSession(modifiedResource, ChatAgentLocation.Chat, CancellationToken.None); ref?.dispose(); } + // When a chat widget exists for `originalResource` but is not an + // `IChatViewViewContext` (e.g. the Agents Window's session-view chat + // widget), that widget owns the rebind to `modifiedResource` via its + // own observer-driven mechanism. Eagerly load+dispose here would + // drop the chat model refcount to 0 between this dispose and the + // widget's async re-acquire, tearing down the ext-host + // `CopilotCLISession` (and its SDK session) — which aborts any + // in-flight request on that session. // Re-send queued requests from the original session on the committed session this._resendPendingRequests(originalResource, modifiedResource); diff --git a/test/automation/src/agentsWindow.ts b/test/automation/src/agentsWindow.ts index 9070add024316..81b652be4af66 100644 --- a/test/automation/src/agentsWindow.ts +++ b/test/automation/src/agentsWindow.ts @@ -448,6 +448,14 @@ export class AgentsWindow { // has rendered, so we additionally enforce a small minimum // quiet period before returning. await this.waitForResponseSettled(15_000, 4_000); + // Synchronize with the workbench-side untitled → committed + // URI swap: the {@link ChatView} sets `data-bound-chat-resource` + // on its root element after binding its inner widget to the + // loaded chat model, and clears it during rebind. Without this + // wait, follow-up typing can land in the about-to-be-replaced + // widget while the rebind is still in flight, losing the typed + // prompt when the widget swaps to the committed session. + await this.waitForActiveChatBoundToCommittedResource(); return text; } } @@ -463,6 +471,32 @@ export class AgentsWindow { throw new Error(`Timed out waiting for assistant text matching ${predicate}\nLast-seen response text(s):\n${seen}\nSession list rows at failure:\n${rowsSummary}\nActive session views:\n${activeSummary}`); } + /** + * Wait until the active session view's {@link ChatView} root advertises + * a non-untitled chat resource via the `data-bound-chat-resource` + * attribute. The Agents Window's `ChatView.setChat` sets this attribute + * after binding its inner chat widget to the loaded chat model, and + * clears it during rebind — so this CSS selector matches precisely + * once the untitled → committed URI swap has landed and the widget is + * bound to the committed chat. + * + * Uses Playwright's `page.waitForSelector` (push-based via + * `MutationObserver` in the renderer) so we don't add any polling on + * the test-driver side. Soft no-op when the selector never matches + * within `timeoutMs` (e.g. for sessions that don't use {@link ChatView} + * such as the local AgentHost smoke tests). + */ + private async waitForActiveChatBoundToCommittedResource(timeoutMs: number = 15_000): Promise { + const selector = `${ACTIVE_SESSION} .chat-view-chat[data-bound-chat-resource]:not([data-bound-chat-resource*="/untitled-"])`; + try { + await this.code.driver.waitForElement(selector, { state: 'attached', timeout: timeoutMs }); + } catch { + // Soft failure: callers have already verified the response text + // is on screen, so proceed and let downstream assertions surface + // any actual problem. + } + } + private async waitForResponseSettled(timeoutMs: number, fallbackQuietMs: number): Promise { const settledSelector = `${RESPONSE}:not(.chat-response-loading)`; const start = Date.now(); diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index a7dbed718ebcc..23eee24bec217 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -32,7 +32,7 @@ interface SessionConfig { } const SESSIONS: readonly SessionConfig[] = [ - { name: 'Copilot', scenarioId: 'smoke-hello-copilot', reply: 'MOCKED_COPILOT_RESPONSE', scenarioId2: 'smoke-hello-copilot-2', reply2: 'MOCKED_COPILOT_RESPONSE_2', skipReply2: true }, + { name: 'Copilot', scenarioId: 'smoke-hello-copilot', reply: 'MOCKED_COPILOT_RESPONSE', scenarioId2: 'smoke-hello-copilot-2', reply2: 'MOCKED_COPILOT_RESPONSE_2' }, { name: 'Claude', scenarioId: 'smoke-hello-claude', reply: 'MOCKED_CLAUDE_RESPONSE', scenarioId2: 'smoke-hello-claude-2', reply2: 'MOCKED_CLAUDE_RESPONSE_2' }, { name: 'Local', scenarioId: 'smoke-hello-local', reply: 'MOCKED_LOCAL_RESPONSE', scenarioId2: 'smoke-hello-local-2', reply2: 'MOCKED_LOCAL_RESPONSE_2' }, ]; @@ -124,7 +124,8 @@ export function setup(logger: Logger) { }); installAllHandlers(logger, opts => { - const copilotEnv = getCopilotSmokeTestEnv(mockServer); + const copilotEnv = getCopilotSmokeTestEnv(mockServer, { userDataDir: opts.userDataDir }); + logger.log(`[Agents Window] XDG_STATE_HOME=${copilotEnv.XDG_STATE_HOME ?? ''}`); logger.log(`[Agents Window] extraEnv keys for app: ${Object.keys(copilotEnv).join(', ')} (token len=${(copilotEnv.VSCODE_COPILOT_CHAT_TOKEN ?? '').length})`); return { ...opts, @@ -166,6 +167,22 @@ export function setup(logger: Logger) { ['sessions.chat.localAgent.enabled', 'true'], ['github.copilot.chat.cli.sandbox.enabled', '"on"'], ['github.copilot.chat.cli.sessionEventLogging.enabled', 'true'], + // Disable multi-chat per Copilot CLI session for this smoke + // test. With multi-chat enabled (default), each follow-up + // turn creates a *new sub-chat* with its own SDK session + // nested under the parent session: the workbench + // auto-swaps the active slot to a fresh new-session + // homepage right after the previous turn commits, and + // each turn ends up in its own isolated worktree + // (`isolationEnabled: true, worktreePath: agents-...`). + // That interferes with the smoke test driver's + // activate/send sequence and makes msg2 land in a + // different VS Code session than the assertion expects. + // With this setting off, `supportsMultipleChats` is + // false for Copilot CLI and turns share a workspace + // (`isolationEnabled: false, worktreePath: undefined`), + // which keeps the test flow deterministic. + ['sessions.github.copilot.multiChatSessions', 'false'], ]); logger.log(`[Agents Window] user settings written; requestCount=${mockServer.requestCount()}`); @@ -223,25 +240,25 @@ export function setup(logger: Logger) { const text = await app.workbench.agentsWindow.waitForAssistantText(session.reply); logger.log(`Agents Window (${session.name}) response 1: ${text}`); - // Copilot CLI: after a request completes, the Agents Window - // auto-switches the active view to a fresh untitled session; - // sending a follow-up prompt there would spawn a brand new - // agent session (with its own session id and branch) rather - // than continuing the existing one. Click back into the - // just-completed session before sending message 2 so the - // follow-up lands in the same session. Identify the row by - // EITHER the first prompt or the msg1 reply: the row text is - // the session title, which starts as the prompt (synchronous - // fallback) and is asynchronously replaced by a generated - // title (the reply, in the mock). Matching either avoids a - // race on when title generation lands. The sessions list also - // contains workspace folder group headers and historical - // sessions, so we can't just click the topmost row. - if (session.name === 'Copilot') { - await app.workbench.agentsWindow.activateSessionByLabel([firstPrompt, session.reply], session.reply); - } - if (!session.skipReply2) { + // Copilot CLI: after a request completes, the Agents Window + // auto-switches the active view to a fresh untitled session; + // sending a follow-up prompt there would spawn a brand new + // agent session (with its own session id and branch) rather + // than continuing the existing one. Click back into the + // just-completed session before sending message 2 so the + // follow-up lands in the same session. Identify the row by + // EITHER the first prompt or the msg1 reply: the row text is + // the session title, which starts as the prompt (synchronous + // fallback) and is asynchronously replaced by a generated + // title (the reply, in the mock). Matching either avoids a + // race on when title generation lands. The sessions list also + // contains workspace folder group headers and historical + // sessions, so we can't just click the topmost row. + if (session.name === 'Copilot') { + await app.workbench.agentsWindow.activateSessionByLabel([firstPrompt, session.reply], session.reply); + } + // Follow-up message in the same session — exercises the // active-session input path (not the new-session homepage). // For Copilot CLI, pass the expected active label so @@ -830,7 +847,7 @@ function setupAgentHostSuite(logger: Logger, config: { ...opts, extraEnv: { ...(opts.extraEnv ?? {}), - ...getCopilotSmokeTestEnv(mockServer), + ...getCopilotSmokeTestEnv(mockServer, { userDataDir: opts.userDataDir }), COPILOT_ENABLE_ALT_PROVIDERS: 'true', COPILOT_API_URL: mockServer.url, COPILOT_DEBUG_GITHUB_API_URL: mockServer.url, diff --git a/test/smoke/src/areas/chat/copilotCli.test.ts b/test/smoke/src/areas/chat/copilotCli.test.ts index 9c606e6c60477..2d6ecb615509c 100644 --- a/test/smoke/src/areas/chat/copilotCli.test.ts +++ b/test/smoke/src/areas/chat/copilotCli.test.ts @@ -43,7 +43,7 @@ export function setup(logger: Logger) { ...opts, extraEnv: { ...(opts.extraEnv ?? {}), - ...getCopilotSmokeTestEnv(mockServer), + ...getCopilotSmokeTestEnv(mockServer, { userDataDir: opts.userDataDir }), }, })); diff --git a/test/smoke/src/utils.ts b/test/smoke/src/utils.ts index f8e614a7bc71b..462f6524583cb 100644 --- a/test/smoke/src/utils.ts +++ b/test/smoke/src/utils.ts @@ -143,7 +143,30 @@ export function buildCopilotChatToken(mockUrl: string): string { })).toString('base64'); } -export function getCopilotSmokeTestEnv(mockServer?: MockLlmServer): Readonly> { +export function getCopilotSmokeTestEnv(mockServer?: MockLlmServer, opts?: { userDataDir?: string }): Readonly> { + // When `userDataDir` is provided, isolate the Copilot CLI session store + // from the user's real `~/.copilot/` by pointing `XDG_STATE_HOME` at a + // sibling of the per-run `userDataDir`. The extension's `getCopilotHome()` + // / `getCopilotCLISessionStateDir()` (in + // `extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts`) + // and the underlying CLI SDK both anchor to `XDG_STATE_HOME/.copilot/` + // when that env var is set, otherwise to `~/.copilot/`. Pinning it under + // the per-run `userDataDir` means the smoke-test cleanup (which removes + // the whole `testDataPath`) also wipes the Copilot state, so repeated + // local runs don't accumulate sessions that slow down `listSessions` + // and other startup paths. + let xdgStateHome: string | undefined; + if (opts?.userDataDir) { + xdgStateHome = `${opts.userDataDir}-copilot-state`; + try { + fs.mkdirSync(xdgStateHome, { recursive: true }); + } catch { + // best effort — the dir will be created by the extension on first + // write if mkdir fails here (e.g. due to a race with a sibling + // suite). The env var is still honoured. + } + } + return { // Mirror the env-var bypass used by `scripts/chat-simulation/common/utils.js#buildEnv` // for perf-regression / memory-leak runs: @@ -156,6 +179,7 @@ export function getCopilotSmokeTestEnv(mockServer?: MockLlmServer): Readonly Date: Tue, 23 Jun 2026 15:39:41 +0200 Subject: [PATCH 026/696] Revert "[TEMPORARY] smoke: loop Codex session test 20x to probe flakiness" This reverts commit c50b851a9cf6b32feaed4c756ac5494dd6cdc647. --- .github/workflows/pr-darwin-test.yml | 23 ++++++++++------------- .github/workflows/pr-linux-test.yml | 15 --------------- .github/workflows/pr-win32-test.yml | 15 --------------- 3 files changed, 10 insertions(+), 43 deletions(-) diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index 914ece49b1cea..971fda8191f1e 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -192,24 +192,21 @@ jobs: timeout-minutes: 20 run: npm run smoketest-no-compile -- --tracing - # TEMPORARY: loop the Codex smoke test to confirm it is not flaky. - # Remove this step (and the matching ones in pr-linux-test.yml / - # pr-win32-test.yml) before merging PR #322366. - - name: 🧪 Codex smoke flakiness probe (TEMPORARY) - if: ${{ inputs.electron_tests && inputs.smoke_tests }} - timeout-minutes: 90 - run: | - for i in $(seq 1 20); do - echo "::group::Codex smoke probe run $i/20" - npm run smoketest-no-compile -- --tracing -g "Test Codex session" || { echo "::error::Codex smoke test failed on run $i/20"; exit 1; } - echo "::endgroup::" - done - - name: 🧪 Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --web --tracing --headless + - name: 🧪 Run smoke tests (Remote) + if: ${{ inputs.remote_tests && inputs.smoke_tests }} + timeout-minutes: 20 + run: npm run smoketest-no-compile -- --remote --tracing + + - name: Diagnostics after smoke test run + if: ${{ inputs.smoke_tests && always() }} + run: ps -ef + continue-on-error: true + - name: Publish Crash Reports uses: actions/upload-artifact@v7 if: failure() diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index bb00b2b44e7ae..113d41cdda3bb 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -372,21 +372,6 @@ jobs: env: DISPLAY: ":10" - # TEMPORARY: loop the Codex smoke test to confirm it is not flaky. - # Remove this step (and the matching ones in pr-darwin-test.yml / - # pr-win32-test.yml) before merging PR #322366. - - name: 🧪 Codex smoke flakiness probe (TEMPORARY) - if: ${{ inputs.electron_tests && inputs.smoke_tests }} - timeout-minutes: 90 - run: | - for i in $(seq 1 20); do - echo "::group::Codex smoke probe run $i/20" - npm run smoketest-no-compile -- --tracing -g "Test Codex session" || { echo "::error::Codex smoke test failed on run $i/20"; exit 1; } - echo "::endgroup::" - done - env: - DISPLAY: ":10" - - name: 🧪 Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index a56b15e98d11b..45681aff7077b 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -224,21 +224,6 @@ jobs: shell: pwsh run: npm run smoketest-no-compile -- --tracing - # TEMPORARY: loop the Codex smoke test to confirm it is not flaky. - # Remove this step (and the matching ones in pr-darwin-test.yml / - # pr-linux-test.yml) before merging PR #322366. - - name: 🧪 Codex smoke flakiness probe (TEMPORARY) - if: ${{ inputs.electron_tests && inputs.smoke_tests }} - timeout-minutes: 90 - shell: pwsh - run: | - for ($i = 1; $i -le 20; $i++) { - Write-Host "::group::Codex smoke probe run $i/20" - npm run smoketest-no-compile -- --tracing -g "Test Codex session" - if ($LASTEXITCODE -ne 0) { Write-Host "::error::Codex smoke test failed on run $i/20"; exit 1 } - Write-Host "::endgroup::" - } - - name: 🧪 Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 From 920acb0a684092c4730ce74d230a5a519e911225 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 23 Jun 2026 15:40:05 +0200 Subject: [PATCH 027/696] do not run this task on worktree creation (#322537) --- .vscode/tasks.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e80188ca5ca23..ff8519414e277 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -510,9 +510,6 @@ "command": "cmd /c \"npm install && npm run watch-transpile\"" }, "inAgents": true, - "runOptions": { - "runOn": "worktreeCreated" - }, "presentation": { "focus": false, "reveal": "never" From d7ea471eb169e426236ccaad2b3def3e57515092 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 23 Jun 2026 15:58:33 +0200 Subject: [PATCH 028/696] sessions: persist new-session aux bar visibility and fix toggle icon (#322541) The new-session view always re-showed the Files (auxiliary bar) view even after the user manually closed it, because each untitled session gets a distinct resource and per-resource saved view state was ignored for untitled sessions. Track a shared INewSessionViewState (persisted to workspace storage) so hiding the aux bar on one new session sticks across session switches and reloads. Also fix the Toggle Side Panel icon showing the wrong state on reload: mainEditorAreaVisibleContext was bound but never initialized, so it kept its default 'true' when the editor area was restored hidden. Initialize it to the actual editor-part visibility like the other layout context keys. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT_CONTROLLER.md | 30 +++++--- .../layout/browser/sessionLayoutController.ts | 67 +++++++++++++++--- .../browser/sessionLayoutController.test.ts | 70 +++++++++++++++++++ src/vs/workbench/browser/contextkeys.ts | 1 + 4 files changed, 148 insertions(+), 20 deletions(-) diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index 78ed6891628c4..5553761a5593d 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -67,11 +67,16 @@ Skipped entirely on mobile web (`isWeb && isMobile`) to avoid disruptive auto-ex strict priority order: 1. **No resource / no workspace** → do nothing. -2. **Untitled session** → open the Files container (`SESSIONS_FILES_CONTAINER_ID`), leave visibility as is. +2. **Untitled session (new-session view)** → all untitled sessions share a single state object + (`_newSessionViewState`, persisted to workspace storage under `sessions.newSessionViewState`): if + the user explicitly hid the aux bar on a new session it stays hidden (across switches *and* + reloads); otherwise the default container (§3.2 step 4) is shown. This is what makes hiding the aux + bar on one new session "stick" when the next new session is opened (each untitled session has a + distinct resource, so per-resource saved state can't carry the choice). 3. **Saved state exists**: - was **hidden** → hide the aux bar and stop. - was visible with an active container → reopen that container and stop. -4. **No saved state (first visit) — defaults**: +4. **No saved state (first visit) — defaults** (`_openDefaultAuxiliaryBarContainer`): - session **has changes** → open the Changes view (`CHANGES_VIEW_ID`). - otherwise → open the Files container. @@ -92,11 +97,12 @@ sessions are visible. ### 3.4 Live visibility tracking Aux-bar visibility is also tracked **live** (not only on session switch) via an -`onDidChangePartVisibility` listener for `AUXILIARYBAR_PART` that re-runs `_captureViewState` for -the active session (skipped on mobile web and while multiple sessions are visible). Without this, -the sync autorun — which re-evaluates whenever the session's changes/workspace state updates, not -just on switch — would find no saved state and re-run the default visibility logic (§3.2), -re-revealing a side bar the user had just hidden. +`onDidChangePartVisibility` listener for `AUXILIARYBAR_PART` (skipped on mobile web and while +multiple sessions are visible). For a titled active session it re-runs `_captureViewState`; for an +untitled active session it updates the shared `_newSessionViewState` (§3.2 step +2). Without this, the sync autorun — which re-evaluates whenever the session's changes/workspace +state updates, not just on switch — would find no saved state and re-run the default visibility logic +(§3.2), re-revealing a side bar the user had just hidden. ### 3.5 Editor reveal on session switch @@ -168,12 +174,16 @@ back to the default-visible logic (§3.2) on the next reload.) ## 6. Persistence -- All state serializes to the workspace-scoped key `sessions.layoutState` on +- All per-session state serializes to the workspace-scoped key `sessions.layoutState` on `IStorageService.onWillSaveState` (`_saveState`), with a `StorageTarget.MACHINE` target. - `_saveState` captures the active session's current view state and working set (skipping untitled / multi-session cases) and writes one `ISessionLayoutEntry` per known session resource. -- `_loadState` reads `sessions.layoutState`; if absent it performs a one-time migration from the - legacy `sessions.workingSets` key and then removes it. Corrupted data is dropped defensively. +- The shared new-session view state (§3.2 step 2) is persisted separately under the workspace-scoped + key `sessions.newSessionViewState` as an `INewSessionViewState` object, written immediately whenever + the user toggles the aux bar on the new-session view (not on shutdown). +- `_loadState` reads `sessions.newSessionViewState` and `sessions.layoutState`; if the latter is + absent it performs a one-time migration from the legacy `sessions.workingSets` key and then removes + it. Corrupted data is dropped defensively. --- diff --git a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts index 6a0e0b7ab2f4d..f9e3e360ee31e 100644 --- a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts @@ -42,6 +42,15 @@ interface ISessionViewState { readonly auxiliaryBarActiveViewContainerId: string | undefined; } +/** + * Shared layout state for the new-session (untitled) view. Untitled sessions + * each have a distinct resource, so a single value carries the user's choices + * across new sessions. + */ +interface INewSessionViewState { + readonly auxiliaryBarVisible: boolean; +} + /** * Full per-session layout state persisted to storage. */ @@ -55,6 +64,8 @@ interface ISessionLayoutEntry { const SESSION_LAYOUT_STATE_KEY = 'sessions.layoutState'; /** Legacy key — read on startup for migration only. */ const WORKING_SETS_STORAGE_KEY = 'sessions.workingSets'; +/** Shared layout state for the new-session (untitled) view. */ +const NEW_SESSION_VIEW_STATE_KEY = 'sessions.newSessionViewState'; export class LayoutController extends Disposable { @@ -65,6 +76,13 @@ export class LayoutController extends Disposable { private readonly _viewStateBySession: ResourceMap; private readonly _workingSets: ResourceMap; private readonly _workingSetSequencer = new Sequencer(); + + /** + * Shared layout state for the new-session view, persisted across reloads. + * `undefined` means no explicit choice yet (aux bar defaults to visible). + */ + private _newSessionViewState: INewSessionViewState | undefined; + private readonly _useModalConfigObs; constructor( @@ -250,7 +268,12 @@ export class LayoutController extends Disposable { return; } const activeSession = this._sessionsService.activeSession.get(); - if (activeSession) { + if (!activeSession) { + return; + } + if (activeSession.status.get() === SessionStatus.Untitled) { + this._setNewSessionViewState({ auxiliaryBarVisible: e.visible }); + } else { this._captureViewState(activeSession.resource); } })); @@ -339,14 +362,27 @@ export class LayoutController extends Disposable { }); } + private _setNewSessionViewState(state: INewSessionViewState): void { + this._newSessionViewState = state; + this._storageService.store(NEW_SESSION_VIEW_STATE_KEY, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + private _syncAuxiliaryBarVisibility(sessionResource: URI | undefined, hasWorkspace: boolean, isUntitled: boolean, hasChanges: boolean): void { if (!sessionResource || !hasWorkspace) { return; } - // On session switch or initial load, restore the saved view state. - // Untitled sessions never carry meaningful saved state. - const savedState = isUntitled ? undefined : this._viewStateBySession.get(sessionResource); + // New-session view: all untitled sessions share one state. + if (isUntitled) { + if (this._newSessionViewState && !this._newSessionViewState.auxiliaryBarVisible) { + this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } else { + this._openDefaultAuxiliaryBarContainer(hasChanges); + } + return; + } + + const savedState = this._viewStateBySession.get(sessionResource); // Honor an explicitly hidden auxiliary bar for this session. if (savedState && !savedState.auxiliaryBarVisible) { @@ -364,10 +400,12 @@ export class LayoutController extends Disposable { return; } - // Default for a session without a saved choice (e.g. fresh or untitled): - // prefer Changes when the session has changes. Otherwise show the Files - // pane, unless the user has hidden/unpinned it — in which case fall back to - // Changes rather than force-opening Files. + // Default for a session without a saved choice. + this._openDefaultAuxiliaryBarContainer(hasChanges); + } + + /** Prefer Changes when the session has changes, otherwise Files (falling back to Changes if Files is hidden). */ + private _openDefaultAuxiliaryBarContainer(hasChanges: boolean): void { if (hasChanges || !this._isAuxiliaryBarContainerPinned(SESSIONS_FILES_CONTAINER_ID)) { this._viewsService.openView(CHANGES_VIEW_ID, false); } else { @@ -382,6 +420,15 @@ export class LayoutController extends Disposable { } private _loadState(): void { + const newSessionRaw = this._storageService.get(NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); + if (newSessionRaw) { + try { + this._newSessionViewState = JSON.parse(newSessionRaw) as INewSessionViewState; + } catch { + this._storageService.remove(NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); + } + } + // Load from new key first const raw = this._storageService.get(SESSION_LAYOUT_STATE_KEY, StorageScope.WORKSPACE); if (raw) { @@ -431,8 +478,8 @@ export class LayoutController extends Disposable { const activeSession = this._sessionsService.activeSession.get(); const multipleVisible = this._sessionsService.visibleSessions.get().length > 1; - // Capture current state for the active session (skip when multiple sessions are visible) - if (activeSession && !multipleVisible) { + // Capture current state for the active session (skip multiple-visible and untitled). + if (activeSession && !multipleVisible && activeSession.status.read(undefined) !== SessionStatus.Untitled) { this._captureViewState(activeSession.resource); } diff --git a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts index 5cedab6b6f251..50211adab7f65 100644 --- a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts @@ -120,6 +120,7 @@ suite('LayoutController', () => { readonly useModal?: 'off' | 'some' | 'all'; readonly workspaceFolders?: readonly { readonly uri: URI }[]; readonly layoutState?: readonly object[]; + readonly newSessionViewState?: { readonly auxiliaryBarVisible: boolean }; } function createLayoutController(options: ICreateOptions = {}): LayoutController { @@ -129,6 +130,9 @@ suite('LayoutController', () => { if (options.layoutState) { storageService.store('sessions.layoutState', JSON.stringify(options.layoutState), StorageScope.WORKSPACE, 0); } + if (options.newSessionViewState) { + storageService.store('sessions.newSessionViewState', JSON.stringify(options.newSessionViewState), StorageScope.WORKSPACE, 0); + } instaService.stub(IStorageService, storageService); const configService = new TestConfigurationService(); @@ -259,6 +263,72 @@ suite('LayoutController', () => { assert.ok(openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID)); }); + test('remembers hidden aux bar across new (untitled) sessions', () => { + createLayoutController(); + const untitled1 = makeSession(URI.parse('session:untitled1'), { status: SessionStatus.Untitled }); + const existing = makeSession(URI.parse('session:existing')); + const untitled2 = makeSession(URI.parse('session:untitled2'), { status: SessionStatus.Untitled }); + + // Open a new (untitled) session — aux bar shows the Files view. + activeSessionObs.set(untitled1, undefined); + assert.ok(openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID)); + + // User hides the aux bar on the new-session view. + partVisibility.set(Parts.AUXILIARYBAR_PART, false); + onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + + // Switch to an existing session and back to a brand new (untitled) session. + activeSessionObs.set(existing, undefined); + + setPartHiddenCalls = []; + openedViewContainers = []; + activeSessionObs.set(untitled2, undefined); + + assert.ok( + setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux bar should stay hidden on the next new session' + ); + assert.ok( + !openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + 'should not re-open the Files view on the next new session' + ); + }); + + test('persists hidden new-session aux bar to storage and restores it after reload', () => { + // First lifetime: user hides the aux bar on the new-session view. + createLayoutController(); + const untitled1 = makeSession(URI.parse('session:untitled1'), { status: SessionStatus.Untitled }); + activeSessionObs.set(untitled1, undefined); + + partVisibility.set(Parts.AUXILIARYBAR_PART, false); + onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + + assert.deepStrictEqual( + JSON.parse(storageService.get('sessions.newSessionViewState', StorageScope.WORKSPACE) ?? ''), + { auxiliaryBarVisible: false }, + 'state should be persisted to storage' + ); + + store.clear(); + + // Second lifetime (reload): a fresh controller with the persisted state. + createLayoutController({ newSessionViewState: { auxiliaryBarVisible: false } }); + const untitled2 = makeSession(URI.parse('session:untitled2'), { status: SessionStatus.Untitled }); + + setPartHiddenCalls = []; + openedViewContainers = []; + activeSessionObs.set(untitled2, undefined); + + assert.ok( + setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux bar should stay hidden after reload' + ); + assert.ok( + !openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + 'should not re-open the Files view after reload' + ); + }); + test('does not open views when session has no workspace', () => { createLayoutController(); const session = makeSession(URI.parse('session:1'), { diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index 678a225f1df49..02ef9f878080a 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -174,6 +174,7 @@ export class WorkbenchContextKeysHandler extends Disposable { // Editor Area this.mainEditorAreaVisibleContext = MainEditorAreaVisibleContext.bindTo(this.contextKeyService); + this.mainEditorAreaVisibleContext.set(this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow)); this.editorTabsVisibleContext = EditorTabsVisibleContext.bindTo(this.contextKeyService); // Sidebar From 26c69df11054193fe1f3ad32cd1f82bf0a9f9270 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:12:39 +0200 Subject: [PATCH 029/696] sessions: cap agent feedback input width to editor and stick to content left (#322542) * sessions: cap agent feedback input width to editor and stick to content left The agent feedback editor input could become wider than the editor on narrow editors, rendering oddly. Cap its max width to 90% of the editor width (still bounded by the existing 400px max) and react to editor layout changes so it re-clamps on resize. Also clamp the input's horizontal position to the editor's content left so it sticks to the left of the text content instead of scrolling on top of the line numbers/glyph margin when the editor is scrolled horizontally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address review feedback on agent feedback input width clamping - Base the max width on the editor content area width (right of contentLeft) instead of the full editor width, since the widget sticks to contentLeft. - Set the textarea min-width inline so the CSS min-width:150px can no longer force the input wider than the available space on very narrow editors. - Guard the horizontal position clamp so the content-left minimum never exceeds the right-most valid position, preventing right-edge overflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentFeedbackEditorInputContribution.ts | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 76cd5cab8c5ef..5e59497288665 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -28,6 +28,10 @@ class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { private static readonly _ID = 'agentFeedback.inputWidget'; private static readonly _MIN_WIDTH = 150; private static readonly _MAX_WIDTH = 400; + // The input should never be wider than the editor itself. Cap it to this + // fraction of the editor width so it doesn't render past the editor bounds + // on narrow editors. + private static readonly _MAX_WIDTH_EDITOR_FRACTION = 0.9; readonly allowEditorOverflow = false; @@ -190,8 +194,16 @@ class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { this._measureElement.textContent = text; const textWidth = this._measureElement.scrollWidth; - // Clamp width between min and max - const width = Math.max(AgentFeedbackInputWidget._MIN_WIDTH, Math.min(textWidth + 10, AgentFeedbackInputWidget._MAX_WIDTH)); + // Clamp width between min and a max that never exceeds the editor width. + // On very narrow editors the max can drop below the nominal minimum, so + // derive an effective minimum that never exceeds the max and apply it + // inline to override the CSS `min-width` (otherwise the textarea would be + // forced back up to its CSS minimum and overflow the editor). + const maxWidth = this._computeMaxWidth(); + const minWidth = Math.min(AgentFeedbackInputWidget._MIN_WIDTH, maxWidth); + const desiredWidth = Math.max(minWidth, textWidth + 10); + const width = Math.min(desiredWidth, maxWidth); + this._inputElement.style.minWidth = `${minWidth}px`; this._inputElement.style.width = `${width}px`; // Reset height to auto then expand to fit all content, with a minimum of 1 line @@ -200,6 +212,15 @@ class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { this._inputElement.style.height = `${newHeight}px`; } + private _computeMaxWidth(): number { + // The widget sticks to the editor's content left edge, so the space it + // has available is the content area width (to the right of the line + // numbers/glyph margin), not the full editor width. + const layoutInfo = this._editor.getLayoutInfo(); + const contentWidth = Math.max(0, layoutInfo.width - layoutInfo.contentLeft); + return Math.min(AgentFeedbackInputWidget._MAX_WIDTH, contentWidth * AgentFeedbackInputWidget._MAX_WIDTH_EDITOR_FRACTION); + } + } export class AgentFeedbackEditorInputContribution extends Disposable implements IEditorContribution { @@ -228,6 +249,14 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements this._updatePosition(); } })); + this._store.add(this._editor.onDidLayoutChange(() => { + if (this._visible && this._widget) { + // The editor resized: re-clamp the input width to the new editor + // width and reposition it. + this._widget.autoSize(); + this._updatePosition(); + } + })); this._store.add(this._editor.onMouseDown((e) => { if (this._isWidgetTarget(e.event.target)) { return; @@ -602,8 +631,16 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements // Clamp vertical position within editor bounds top = Math.max(0, Math.min(top, layoutInfo.height - widgetHeight)); - // Clamp horizontal position so the widget stays within the editor - const left = Math.max(0, Math.min(scrolledPosition.left, layoutInfo.width - widgetWidth)); + // Clamp horizontal position so the widget stays within the editor and + // never renders on top of the line numbers/glyph margin (content left). + // When the editor is scrolled horizontally the cursor position can fall + // behind the content area, so stick the widget to the content left edge. + // Guard that the left edge (content left) never exceeds the right-most + // valid position, otherwise the widget would overflow the editor's right + // edge on very narrow editors or with a wide widget. + const minLeft = layoutInfo.contentLeft; + const maxLeft = Math.max(minLeft, layoutInfo.width - widgetWidth); + const left = Math.max(minLeft, Math.min(scrolledPosition.left, maxLeft)); this._widget.setPosition({ preference: { top, left } }); } From 12717879c47434790e4e956f8e211dc6b1948efa Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Tue, 23 Jun 2026 15:43:24 +0100 Subject: [PATCH 030/696] introduce ToolAndToolSetEnablementMap, toolset not used in 'tools' list, only tools (#322548) --- .../chat/browser/actions/chatActions.ts | 6 +-- .../chat/browser/actions/chatToolPicker.ts | 26 +++++------ .../chat/browser/attachments/chatVariables.ts | 8 ++-- .../promptSyntax/promptFileRewriter.ts | 6 +-- .../tools/languageModelToolsService.ts | 25 ++++++++--- .../browser/tools/toolSetsContribution.ts | 12 +++--- .../browser/widget/input/chatSelectedTools.ts | 14 +++--- .../chat/common/attachments/chatVariables.ts | 4 +- .../common/chatService/chatServiceImpl.ts | 4 +- .../common/requestParser/chatRequestParser.ts | 4 +- .../common/tools/languageModelToolsService.ts | 43 +++++++++++++++++-- .../agentHostClientTools.test.ts | 4 +- .../browser/attachments/chatVariables.test.ts | 4 +- .../tools/languageModelToolsService.test.ts | 8 ++-- .../tools/toolSetsContribution.test.ts | 12 +++--- .../widget/input/chatSelectedTools.test.ts | 6 +-- .../chat/test/common/mockChatVariables.ts | 10 ++--- .../requestParser/chatRequestParser.test.ts | 12 +++--- .../tools/mockLanguageModelToolsService.ts | 6 +-- 19 files changed, 132 insertions(+), 82 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 7f7d1479a7e17..ded75a8106a52 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -61,7 +61,7 @@ import { AICustomizationManagementCommands } from '../aiCustomization/aiCustomiz import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { ILanguageModelToolsService, IToolData, IToolSet, isToolSet } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolSet, isToolSet, ToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; import { ChatViewId, IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput, showClearEditingSessionConfirmation } from '../widgetHosts/editor/chatEditorInput.js'; @@ -1546,7 +1546,7 @@ export interface IToolFilteringOptions { } export interface IToolFilteringResult { - enablementMap: Map; + enablementMap: ToolAndToolSetEnablementMap; unknownIdentifiers: string[]; } @@ -1696,7 +1696,7 @@ export function computeToolEnablementMap(options: IToolFilteringOptions): IToolF enablementMap.set(toolSet, allToolsEnabled); } - return { enablementMap, unknownIdentifiers }; + return { enablementMap: ToolAndToolSetEnablementMap.fromMap(enablementMap), unknownIdentifiers }; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 4821ad3825ecc..479b5903815a1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -28,7 +28,7 @@ import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpS import { startServerAndWaitForLiveTools } from '../../../mcp/common/mcpTypesUtils.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolSet, ToolAndToolSetEnablementMap, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; import { ConfigureToolSets, deleteToolSetFromFileContents } from '../tools/toolSetsContribution.js'; const enum BucketOrdinal { User, BuiltIn, Mcp, Extension } @@ -203,10 +203,10 @@ export async function showToolsPicker( placeHolder: string, source: string, description?: string, - getToolsEntries?: () => ReadonlyMap, + getToolsEntries?: () => ToolAndToolSetEnablementMap, model?: ILanguageModelChatMetadata | undefined, token?: CancellationToken -): Promise | undefined> { +): Promise { const quickPickService = accessor.get(IQuickInputService); const mcpService = accessor.get(IMcpService); @@ -265,7 +265,7 @@ export async function showToolsPicker( } } - function computeItems(previousToolsEntries?: ReadonlyMap) { + function computeItems(previousToolsEntries?: ToolAndToolSetEnablementMap) { // Create default entries if none provided let toolsEntries = getToolsEntries ? new Map([...getToolsEntries()].map(([k, enabled]) => [k.id, enabled])) : undefined; if (!toolsEntries) { @@ -280,9 +280,9 @@ export async function showToolsPicker( } toolsEntries = defaultEntries; } - previousToolsEntries?.forEach((value, key) => { - toolsEntries.set(key.id, value); - }); + for (const [entry, enabled] of previousToolsEntries ?? []) { + toolsEntries.set(entry.id, enabled); + } // Build tree structure const treeItems: AnyTreeItem[] = []; @@ -563,7 +563,7 @@ export async function showToolsPicker( } })); - const collectResults = () => { + const collectResults = (): ToolAndToolSetEnablementMap => { const result = new Map(); const traverse = (items: readonly AnyTreeItem[]) => { @@ -596,7 +596,7 @@ export async function showToolsPicker( }; traverse(treePicker.itemTree); - return result; + return ToolAndToolSetEnablementMap.fromMap(result); }; // Handle acceptance @@ -729,8 +729,8 @@ interface IToolToggleSummary { } function computeToolToggleSummary( - initialState: ReadonlyMap, - finalState: ReadonlyMap, + initialState: ToolAndToolSetEnablementMap, + finalState: ToolAndToolSetEnablementMap, mcpRegistry: IMcpRegistry ): IToolToggleSummary { const summary: IToolToggleSummary = { @@ -793,8 +793,8 @@ function computeToolToggleSummary( function sendDidChangeEvent( source: string, telemetryService: ITelemetryService, - initialState: ReadonlyMap, - finalState: ReadonlyMap, + initialState: ToolAndToolSetEnablementMap, + finalState: ToolAndToolSetEnablementMap, mcpRegistry: IMcpRegistry ): void { const summary = computeToolToggleSummary(initialState, finalState, mcpRegistry); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts index ffdb625d5c9f1..5f0273053de12 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IChatVariablesService, IDynamicVariable } from '../../common/attachments/chatVariables.js'; -import { IToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; +import { ToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { ChatDynamicVariableModel } from './chatDynamicVariables.js'; import { Range } from '../../../../../editor/common/core/range.js'; @@ -67,7 +67,7 @@ export function getDynamicVariablesForWidget(widget: IChatWidget): ReadonlyArray return model.variables; } -export function getSelectedToolAndToolSetsForWidget(widget: IChatWidget): IToolAndToolSetEnablementMap { +export function getSelectedToolAndToolSetsForWidget(widget: IChatWidget): ToolAndToolSetEnablementMap { return widget.input.selectedToolsModel.entriesMap.get(); } @@ -86,10 +86,10 @@ export class ChatVariablesService implements IChatVariablesService { return getDynamicVariablesForWidget(widget); } - getSelectedToolAndToolSets(sessionResource: URI): IToolAndToolSetEnablementMap { + getSelectedToolAndToolSets(sessionResource: URI): ToolAndToolSetEnablementMap { const widget = this.chatWidgetService.getWidgetBySessionResource(sessionResource); if (!widget) { - return new Map(); + return ToolAndToolSetEnablementMap.fromEntries([]); } return getSelectedToolAndToolSetsForWidget(widget); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index 302ab0356b31c..fb2610c083f5d 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts @@ -9,7 +9,7 @@ import { ICodeEditorService } from '../../../../../editor/browser/services/codeE import { EditOperation } from '../../../../../editor/common/core/editOperation.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { ITextModel } from '../../../../../editor/common/model.js'; -import { ILanguageModelToolsService, IToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, ToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; import { PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { formatArrayValue } from '../../common/promptSyntax/utils/promptEditHelper.js'; @@ -22,7 +22,7 @@ export class PromptFileRewriter { ) { } - public async openAndRewriteTools(uri: URI, newTools: IToolAndToolSetEnablementMap | undefined, token: CancellationToken): Promise { + public async openAndRewriteTools(uri: URI, newTools: ToolAndToolSetEnablementMap | undefined, token: CancellationToken): Promise { const editor = await this._codeEditorService.openCodeEditor({ resource: uri }, this._codeEditorService.getFocusedCodeEditor()); if (!editor || !editor.hasModel()) { return; @@ -48,7 +48,7 @@ export class PromptFileRewriter { } } - public rewriteTools(model: ITextModel, newTools: IToolAndToolSetEnablementMap, range: Range, isString: boolean): void { + public rewriteTools(model: ITextModel, newTools: ToolAndToolSetEnablementMap, range: Range, isString: boolean): void { const newToolNames = this._languageModelToolsService.toFullReferenceNames(newTools); const newEntries = newToolNames.map(toolName => formatArrayValue(toolName)).join(', '); const newValue = isString ? newEntries : `[${newEntries}]`; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 4e20c07a85eed..3a25dfd04d43f 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -52,7 +52,7 @@ import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { CopilotChatSettingId, CopilotToolId } from '../../common/tools/copilotToolIds.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { TerminalToolId } from '../../common/tools/terminalToolIds.js'; -import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolAndToolSetEnablementMap, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; import { IToolResultCompressor } from '../../common/tools/toolResultCompressor.js'; import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; import { IChatWidgetService } from '../chat.js'; @@ -1593,7 +1593,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo * @param fullReferenceNames A list of tool or toolset by their full reference names that are enabled. * @returns A map of tool or toolset instances to their enablement state. */ - toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): ToolAndToolSetEnablementMap { const toolOrToolSetNames = new Set(fullReferenceNames); const result = new Map(); for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { @@ -1631,22 +1631,35 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo result.set(toolSet, enabled); } } - return result; + return ToolAndToolSetEnablementMap.fromMap(result); } - toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[] { + toFullReferenceNames(map: ToolAndToolSetEnablementMap): string[] { const result: string[] = []; const toolsCoveredByEnabledToolSet = new Set(); + + // compare by id as toolset instances may be different (e.g. ToolSetForModel) + const eneabledToolSetIds = new Set(); + const eneabledToolIds = new Set(); + for (const [tool, enabled] of map) { + if (enabled) { + if (isToolSet(tool)) { + eneabledToolSetIds.add(tool.id); + } else { + eneabledToolIds.add(tool.id); + } + } + } for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { if (isToolSet(tool)) { - if (map.get(tool)) { + if (eneabledToolSetIds.has(tool.id)) { result.push(fullReferenceName); for (const memberTool of tool.getTools()) { toolsCoveredByEnabledToolSet.add(memberTool); } } } else { - if (map.get(tool) && !toolsCoveredByEnabledToolSet.has(tool)) { + if (eneabledToolIds.has(tool.id) && !toolsCoveredByEnabledToolSet.has(tool)) { result.push(fullReferenceName); } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts index 5d50e9232e067..fdc75bdf394ce 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts @@ -25,7 +25,7 @@ import { IExtensionService } from '../../../../services/extensions/common/extens import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; -import { ILanguageModelToolsService, IToolData, IToolSet, isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolSet, isToolSet, ToolAndToolSetEnablementMap, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; import { IRawToolSetContribution } from '../../common/tools/languageModelToolsContribution.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { Codicon, getAllCodicons } from '../../../../../base/common/codicons.js'; @@ -322,23 +322,23 @@ export class UserToolSetsContributions extends Disposable implements IWorkbenchC } export interface IConfigureToolSetsOptions { - readonly selection?: ReadonlyMap; + readonly selection?: ToolAndToolSetEnablementMap; } -function getSelectionFromArg(arg: unknown): ReadonlyMap | undefined { +function getSelectionFromArg(arg: unknown): ToolAndToolSetEnablementMap | undefined { if (!isObject(arg)) { return undefined; } const selection = (arg as IConfigureToolSetsOptions).selection; - if (!(selection instanceof Map)) { + if (!(selection instanceof ToolAndToolSetEnablementMap)) { return undefined; } return selection; } -export function getEnabledSelectionReferences(selection: ReadonlyMap, toolsService: ILanguageModelToolsService): string[] { +export function getEnabledSelectionReferences(selection: ToolAndToolSetEnablementMap, toolsService: ILanguageModelToolsService): string[] { const enabledToolSets: IToolSet[] = []; const enabledTools: IToolData[] = []; @@ -474,7 +474,7 @@ export class ConfigureToolSets extends Action2 { // view title menu), fall back to the tool selection of the active chat widget. const currentSelection = getSelectionFromArg(options) ?? chatWidgetService.lastFocusedWidget?.input.selectedToolsModel.entriesMap.get() - ?? new Map(); + ?? ToolAndToolSetEnablementMap.fromEntries([]); const selectedReferences = getEnabledSelectionReferences(currentSelection, toolsService); if (selectedReferences.length > 0) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts index bf0b3dfb98ca4..74195e8684cdd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts @@ -16,7 +16,7 @@ import { ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; import { UserSelectedTools } from '../../../common/participants/chatAgents.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolSet, isToolSet } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, isToolSet, ToolAndToolSetEnablementMap, IToolSet } from '../../../common/tools/languageModelToolsService.js'; import { PromptFileRewriter } from '../../promptSyntax/promptFileRewriter.js'; @@ -40,9 +40,9 @@ type StoredDataV1 = { }; namespace ToolEnablementStates { - export function fromMap(map: IToolAndToolSetEnablementMap): ToolEnablementStates { + export function fromMap(map: ToolAndToolSetEnablementMap): ToolEnablementStates { const toolSets: Map = new Map(), tools: Map = new Map(); - for (const [entry, enabled] of map.entries()) { + for (const [entry, enabled] of map) { if (isToolSet(entry)) { toolSets.set(entry.id, enabled); } else { @@ -128,7 +128,7 @@ export class ChatSelectedTools extends Disposable { * All tools and tool sets with their enabled state. * Tools are filtered based on the current model context. */ - public readonly entriesMap: IObservable = derived(r => { + public readonly entriesMap: IObservable = derived(r => { const map = new Map(); const lm = this.languageModel.read(r)?.metadata; @@ -157,7 +157,7 @@ export class ChatSelectedTools extends Disposable { map.set(tool, toolSetEnabled || currentMap.tools.get(tool.id) === true); // if unknown, use toolSetEnabled } } - return map; + return ToolAndToolSetEnablementMap.fromMap(map); }); public readonly userSelectedTools: IObservable = derived(r => { @@ -192,7 +192,7 @@ export class ChatSelectedTools extends Disposable { this._sessionStates.delete(mode.id); } - set(enablementMap: IToolAndToolSetEnablementMap, sessionOnly: boolean): void { + set(enablementMap: ToolAndToolSetEnablementMap, sessionOnly: boolean): void { const mode = this._mode.get(); if (sessionOnly || this._sessionStates.has(mode.id)) { this._sessionStates.set(mode.id, ToolEnablementStates.fromMap(enablementMap)); @@ -212,7 +212,7 @@ export class ChatSelectedTools extends Disposable { this._globalState.set(ToolEnablementStates.fromMap(enablementMap), undefined); } - private async updateCustomModeTools(uri: URI, enablementMap: IToolAndToolSetEnablementMap): Promise { + private async updateCustomModeTools(uri: URI, enablementMap: ToolAndToolSetEnablementMap): Promise { await this._instantiationService.createInstance(PromptFileRewriter).openAndRewriteTools(uri, enablementMap, CancellationToken.None); } } diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts index 0c8846e699409..f46350849201f 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts @@ -12,7 +12,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IChatModel } from '../model/chatModel.js'; import { IChatContentReference, IChatProgressMessage } from '../chatService/chatService.js'; import { IDiagnosticVariableEntryFilterData, StringChatContextValue } from './chatVariableEntries.js'; -import { IToolAndToolSetEnablementMap } from '../tools/languageModelToolsService.js'; +import { ToolAndToolSetEnablementMap } from '../tools/languageModelToolsService.js'; export interface IChatVariableData { id: string; @@ -47,7 +47,7 @@ export const IChatVariablesService = createDecorator('ICh export interface IChatVariablesService { _serviceBrand: undefined; getDynamicVariables(sessionResource: URI): ReadonlyArray; - getSelectedToolAndToolSets(sessionResource: URI): IToolAndToolSetEnablementMap; + getSelectedToolAndToolSets(sessionResource: URI): ToolAndToolSetEnablementMap; } export interface IDynamicVariable { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 1228ecc883ffe..bfc6e0e131d29 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -50,7 +50,7 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isExplicitFileOrImag import { IDynamicVariable } from '../attachments/chatVariables.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../languageModels.js'; -import { ILanguageModelToolsService, IToolAndToolSetEnablementMap } from '../tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, ToolAndToolSetEnablementMap } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_COMMAND_NAME, TROUBLESHOOT_SKILL_PATH, COPILOT_SKILL_URI_SCHEME } from '../promptSyntax/promptTypes.js'; @@ -118,7 +118,7 @@ class CancellableRequest implements IDisposable { } const EMPTY_REFERENCES: ReadonlyArray = Object.freeze([]); -const EMPTY_TOOL_ENABLEMENT_MAP: IToolAndToolSetEnablementMap = new Map(); +const EMPTY_TOOL_ENABLEMENT_MAP: ToolAndToolSetEnablementMap = ToolAndToolSetEnablementMap.fromEntries([]); export class ChatService extends Disposable implements IChatService { declare _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index b3387f67c432b..c549c25095e31 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -13,7 +13,7 @@ import { getChatSessionType } from '../model/chatUri.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IPromptsService, matchesSessionType } from '../promptSyntax/service/promptsService.js'; -import { IToolAndToolSetEnablementMap, IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; +import { ToolAndToolSetEnablementMap, IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; export const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent @@ -47,7 +47,7 @@ export class ChatRequestParser { return this.parseChatRequestWithReferences(references, selectedToolAndToolSets, message, location, context); } - parseChatRequestWithReferences(references: ReadonlyArray, selectedToolAndToolSets: IToolAndToolSetEnablementMap, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context?: IChatParserContext): IParsedChatRequest { + parseChatRequestWithReferences(references: ReadonlyArray, selectedToolAndToolSets: ToolAndToolSetEnablementMap, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context?: IChatParserContext): IParsedChatRequest { const parts: IParsedChatRequestPart[] = []; const toolsByName = new Map(); const toolSetsByName = new Map(); diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 8e51d211b5c8a..0383fee659c76 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -415,7 +415,44 @@ export interface IToolSet { getTools(r?: IReader): Iterable; } -export type IToolAndToolSetEnablementMap = ReadonlyMap; + + +/** + * Maps tools and tool sets to their enablement state. Use a class to make control the creation of the map and ensure that it is not mutated after creation. + */ +export class ToolAndToolSetEnablementMap implements Iterable<[IToolData | IToolSet, boolean]> { + + static fromEntries(entries: Iterable<[IToolData | IToolSet, boolean]>): ToolAndToolSetEnablementMap { + return new ToolAndToolSetEnablementMap(new Map(entries)); + } + + static fromMap(map: Map): ToolAndToolSetEnablementMap { + return new ToolAndToolSetEnablementMap(new Map(map)); + } + + private constructor(private readonly _map: Map) { + } + + [Symbol.iterator](): IterableIterator<[IToolData | IToolSet, boolean]> { + return this._map[Symbol.iterator](); + } + + public get(toolOrToolSet: IToolData | IToolSet): boolean | undefined { + return this._map.get(toolOrToolSet); + } + + public has(toolOrToolSet: IToolData | IToolSet): boolean { + return this._map.has(toolOrToolSet); + } + + public get size(): number { + return this._map.size; + } + + public entries(): IterableIterator<[IToolData | IToolSet, boolean]> { + return this._map.entries(); + } +} export function isToolSet(obj: IToolData | IToolSet | undefined): obj is IToolSet { return !!obj && (obj as IToolSet).getTools !== undefined; @@ -623,9 +660,9 @@ export interface ILanguageModelToolsService { * @param model Optional language model metadata to filter tools by. * If undefined is passed, all tools will be returned, even if normally disabled. */ - toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap; + toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): ToolAndToolSetEnablementMap; - toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[]; + toFullReferenceNames(map: ToolAndToolSetEnablementMap): string[]; toToolReferences(variableReferences: readonly IVariableReference[]): ChatRequestToolReferenceEntry[]; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 83a384df3c14c..ddc87f7852a8a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -41,7 +41,7 @@ import { IAgentSubscription } from '../../../../../../platform/agentHost/common/ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; -import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolAndToolSetEnablementMap, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; @@ -301,7 +301,7 @@ suite('AgentHostClientTools', () => { getFullReferenceNameMap: () => new Map(), getToolByFullReferenceName: () => undefined, getDeprecatedFullReferenceNames: () => new Map(), - toToolAndToolSetEnablementMap: () => new Map(), + toToolAndToolSetEnablementMap: () => ToolAndToolSetEnablementMap.fromEntries([]), toFullReferenceNames: () => [], toToolReferences: () => [], vscodeToolSet: undefined!, diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts index 59e05f0411bb7..2cc0e9a2da348 100644 --- a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts @@ -11,7 +11,7 @@ import { IChatWidget } from '../../../browser/chat.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../../browser/attachments/chatVariables.js'; import { ChatDynamicVariableModel } from '../../../browser/attachments/chatDynamicVariables.js'; import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { IToolData, IToolSet, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { IToolData, ToolDataSource, ToolAndToolSetEnablementMap } from '../../../common/tools/languageModelToolsService.js'; import { observableValue } from '../../../../../../base/common/observable.js'; function createMockVariable(overrides?: Partial): IDynamicVariable { @@ -176,7 +176,7 @@ suite('getSelectedToolAndToolSetsForWidget', () => { canBeReferencedInPrompt: true, source: ToolDataSource.Internal, }; - const expectedMap = new Map([[toolData, true]]); + const expectedMap = ToolAndToolSetEnablementMap.fromEntries([[toolData, true]]); const entriesMap = observableValue('test', expectedMap); const widget = { diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index df53fa3e8ca1d..ec5da7af3f759 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -27,7 +27,7 @@ import { IChatToolRiskAssessmentService, IToolRiskAssessment, ToolRiskLevel, Too import { ChatModel, IChatModel } from '../../../common/model/chatModel.js'; import { IChatService, IChatProgress, IChatInfoMessage, IChatToolInputInvocationData, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; -import { SpecedToolAliases, isToolResultInputOutputDetails, IToolData, IToolImpl, IToolInvocation, ToolDataSource, ToolSet, IToolResultTextPart } from '../../../common/tools/languageModelToolsService.js'; +import { SpecedToolAliases, isToolResultInputOutputDetails, IToolData, IToolImpl, IToolInvocation, ToolDataSource, IToolResultTextPart, ToolAndToolSetEnablementMap } from '../../../common/tools/languageModelToolsService.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; @@ -814,7 +814,7 @@ suite('LanguageModelToolsService', () => { // Test with some enabled tool { // creating a map by hand is a no-go, we just do it for this test - const map = new Map([[tool1, true], [extTool1, true], [mcpToolSet, true], [mcpTool1, true]]); + const map = ToolAndToolSetEnablementMap.fromEntries([[tool1, true], [extTool1, true], [mcpToolSet, true], [mcpTool1, true]]); const fullReferenceNames = service.toFullReferenceNames(map); const expectedFullReferenceNames = ['tool1RefName', 'my.extension/extTool1RefName', 'mcpToolSetRefName/*']; assert.deepStrictEqual(fullReferenceNames.sort(), expectedFullReferenceNames.sort(), 'toFullReferenceNames should return the original enabled names'); @@ -822,7 +822,7 @@ suite('LanguageModelToolsService', () => { // Test with user data { // creating a map by hand is a no-go, we just do it for this test - const map = new Map([[tool1, true], [userToolSet, true], [internalToolSet, false], [internalTool, true]]); + const map = ToolAndToolSetEnablementMap.fromEntries([[tool1, true], [userToolSet, true], [internalToolSet, false], [internalTool, true]]); const fullReferenceNames = service.toFullReferenceNames(map); const expectedFullReferenceNames = ['tool1RefName', 'internalToolSetRefName/internalToolSetTool1RefName']; assert.deepStrictEqual(fullReferenceNames.sort(), expectedFullReferenceNames.sort(), 'toFullReferenceNames should return the original enabled names'); @@ -830,7 +830,7 @@ suite('LanguageModelToolsService', () => { // Test with unknown tool and tool set { // creating a map by hand is a no-go, we just do it for this test - const map = new Map([[unknownTool, true], [unknownToolSet, true], [internalToolSet, true], [internalTool, true]]); + const map = ToolAndToolSetEnablementMap.fromEntries([[unknownTool, true], [unknownToolSet, true], [internalToolSet, true], [internalTool, true]]); const fullReferenceNames = service.toFullReferenceNames(map); const expectedFullReferenceNames = ['internalToolSetRefName']; assert.deepStrictEqual(fullReferenceNames.sort(), expectedFullReferenceNames.sort(), 'toFullReferenceNames should return the original enabled names'); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts index 7d3b694b68240..705c4d777ad5d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts @@ -11,7 +11,7 @@ import { ContextKeyService } from '../../../../../../platform/contextkey/browser import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { LanguageModelToolsService } from '../../../browser/tools/languageModelToolsService.js'; import { createToolSetFileContents, deleteToolSetFromFileContents, getEnabledSelectionReferences } from '../../../browser/tools/toolSetsContribution.js'; -import { IToolData, IToolSet, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { IToolData, ToolDataSource, ToolAndToolSetEnablementMap } from '../../../common/tools/languageModelToolsService.js'; suite('ToolSetsContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -53,7 +53,7 @@ suite('ToolSetsContribution', () => { )); store.add(userToolSet.addTool(coveredTool)); - const selection = new Map([ + const selection = ToolAndToolSetEnablementMap.fromEntries([ [userToolSet, true], [coveredTool, true], [standaloneTool, true], @@ -96,7 +96,7 @@ suite('ToolSetsContribution', () => { store.add(userToolSet.addTool(enabledTool)); store.add(userToolSet.addTool(disabledTool)); - const selection = new Map([ + const selection = ToolAndToolSetEnablementMap.fromEntries([ [userToolSet, true], [enabledTool, true], [disabledTool, false], @@ -130,7 +130,7 @@ suite('ToolSetsContribution', () => { )); store.add(vscodeToolSet.addTool(memoryTool)); - const selection = new Map([ + const selection = ToolAndToolSetEnablementMap.fromEntries([ [vscodeToolSet, false], [memoryTool, true], ]); @@ -157,7 +157,7 @@ suite('ToolSetsContribution', () => { const vscodeToolSet = store.add(toolsService.createToolSet(ToolDataSource.Internal, 'vscode', 'vscode')); store.add(vscodeToolSet.addTool(subTool)); - const selection = new Map([ + const selection = ToolAndToolSetEnablementMap.fromEntries([ [vscodeToolSet, false], [subTool, true], ]); @@ -225,7 +225,7 @@ suite('ToolSetsContribution', () => { store.add(readToolSet.addTool(readFileTool)); store.add(githubToolSet.addTool(githubIssuesTool)); - const selection = new Map([ + const selection = ToolAndToolSetEnablementMap.fromEntries([ [vscodeToolSet, false], [executeToolSet, false], [readToolSet, false], diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts index 9860fdcf3796b..77e2114512731 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts @@ -9,7 +9,7 @@ import { ContextKeyService } from '../../../../../../../platform/contextkey/brow import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { LanguageModelToolsService } from '../../../../browser/tools/languageModelToolsService.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; -import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, ToolAndToolSetEnablementMap, ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; import { MockChatService } from '../../../common/chatService/mockChatService.js'; import { ChatSelectedTools } from '../../../../browser/widget/input/chatSelectedTools.js'; import { constObservable } from '../../../../../../../base/common/observable.js'; @@ -104,7 +104,7 @@ suite('ChatSelectedTools', () => { assert.strictEqual(selectedTools.entriesMap.get().size, 8); // 1 toolset (+4 vscode, execute, read, agent toolsets), 3 tools - const toSet = new Map([[toolData1, true], [toolData2, false], [toolData3, false], [toolset, false]]); + const toSet = ToolAndToolSetEnablementMap.fromEntries([[toolData1, true], [toolData2, false], [toolData3, false], [toolset, false]]); selectedTools.set(toSet, false); const userSelectedTools = selectedTools.userSelectedTools.get(); @@ -169,7 +169,7 @@ suite('ChatSelectedTools', () => { assert.strictEqual(selectedTools.entriesMap.get().size, 8); // 1 toolset (+4 vscode, execute, read, agent toolsets), 3 tools // Toolset is checked, tools 2 and 3 are unchecked - const toSet = new Map([[toolData1, true], [toolData2, false], [toolData3, false], [toolset, true]]); + const toSet = ToolAndToolSetEnablementMap.fromEntries([[toolData1, true], [toolData2, false], [toolData3, false], [toolset, true]]); selectedTools.set(toSet, false); const userSelectedTools = selectedTools.userSelectedTools.get(); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts b/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts index 7e8d3e487a289..51b64ada9bf09 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts @@ -6,27 +6,27 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { URI } from '../../../../../base/common/uri.js'; import { IChatVariablesService, IDynamicVariable } from '../../common/attachments/chatVariables.js'; -import { IToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; +import { ToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; export class MockChatVariablesService implements IChatVariablesService { _serviceBrand: undefined; private _dynamicVariables = new ResourceMap(); - private _selectedToolAndToolSets = new ResourceMap(); + private _selectedToolAndToolSets = new ResourceMap(); getDynamicVariables(sessionResource: URI): readonly IDynamicVariable[] { return this._dynamicVariables.get(sessionResource) ?? []; } - getSelectedToolAndToolSets(sessionResource: URI): IToolAndToolSetEnablementMap { - return this._selectedToolAndToolSets.get(sessionResource) ?? new Map(); + getSelectedToolAndToolSets(sessionResource: URI): ToolAndToolSetEnablementMap { + return this._selectedToolAndToolSets.get(sessionResource) ?? ToolAndToolSetEnablementMap.fromEntries([]); } setDynamicVariables(sessionResource: URI, variables: readonly IDynamicVariable[]): void { this._dynamicVariables.set(sessionResource, variables); } - setSelectedToolAndToolSets(sessionResource: URI, tools: IToolAndToolSetEnablementMap): void { + setSelectedToolAndToolSets(sessionResource: URI, tools: ToolAndToolSetEnablementMap): void { this._selectedToolAndToolSets.set(sessionResource, tools); } } diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts index eba570bf84a5f..5cab337530e06 100644 --- a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts @@ -22,7 +22,7 @@ import { IChatSlashCommandService } from '../../../common/participants/chatSlash import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; -import { IToolData, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; +import { IToolData, ToolAndToolSetEnablementMap, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { MockChatService } from '../chatService/mockChatService.js'; import { MockChatVariablesService } from '../mockChatVariables.js'; @@ -331,7 +331,7 @@ suite('ChatRequestParser', () => { const forcedAgent = { ...getAgentWithSlashCommands([]), capabilities: { supportsPromptAttachments: true } } satisfies IChatAgentData; const result = parser.parseChatRequestWithReferences( [], - new Map(), + ToolAndToolSetEnablementMap.fromEntries([]), '/skill plan run a quick plan', ChatAgentLocation.Chat, { sessionType: 'agent-host-copilot', forcedAgent, attachmentCapabilities: forcedAgent.capabilities }, @@ -356,7 +356,7 @@ suite('ChatRequestParser', () => { const forcedAgent = { ...getAgentWithSlashCommands([]), capabilities: { supportsPromptAttachments: true } } satisfies IChatAgentData; const result = parser.parseChatRequestWithReferences( [], - new Map(), + ToolAndToolSetEnablementMap.fromEntries([]), '/compact', ChatAgentLocation.Chat, { sessionType: 'agent-host-copilot', forcedAgent, attachmentCapabilities: forcedAgent.capabilities, mode: ChatModeKind.Agent }, @@ -380,7 +380,7 @@ suite('ChatRequestParser', () => { parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequestWithReferences( [], - new Map(), + ToolAndToolSetEnablementMap.fromEntries([]), '/skill plan run a quick plan', ChatAgentLocation.Chat, { sessionType: 'agent-host-copilot' }, @@ -481,7 +481,7 @@ suite('ChatRequestParser', () => { agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService); - variableService.setSelectedToolAndToolSets(testSessionUri, new Map([ + variableService.setSelectedToolAndToolSets(testSessionUri, ToolAndToolSetEnablementMap.fromEntries([ [{ id: 'get_selection', toolReferenceName: 'selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: ToolDataSource.Internal }, true], [{ id: 'get_debugConsole', toolReferenceName: 'debugConsole', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: ToolDataSource.Internal }, true] ] satisfies [IToolData | ToolSet, boolean][])); @@ -496,7 +496,7 @@ suite('ChatRequestParser', () => { agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService); - variableService.setSelectedToolAndToolSets(testSessionUri, new Map([ + variableService.setSelectedToolAndToolSets(testSessionUri, ToolAndToolSetEnablementMap.fromEntries([ [{ id: 'get_selection', toolReferenceName: 'selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: ToolDataSource.Internal }, true], [{ id: 'get_debugConsole', toolReferenceName: 'debugConsole', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: ToolDataSource.Internal }, true] ] satisfies [IToolData | ToolSet, boolean][])); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index fa55704479a2c..1dfd4b8ca5eb2 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -16,7 +16,7 @@ import { ChatRequestToolReferenceEntry } from '../../../common/attachments/chatV import { IVariableReference } from '../../../common/chatModes.js'; import { IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; -import { CountTokensCallback, IBeginToolCallOptions, ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolSet, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, IBeginToolCallOptions, ILanguageModelToolsService, ToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolSet, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; export class MockLanguageModelToolsService extends Disposable implements ILanguageModelToolsService { _serviceBrand: undefined; @@ -159,7 +159,7 @@ export class MockLanguageModelToolsService extends Disposable implements ILangua throw new Error('Method not implemented.'); } - toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[]): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[]): ToolAndToolSetEnablementMap { throw new Error('Method not implemented.'); } @@ -183,7 +183,7 @@ export class MockLanguageModelToolsService extends Disposable implements ILangua throw new Error('Method not implemented.'); } - toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[] { + toFullReferenceNames(map: ToolAndToolSetEnablementMap): string[] { throw new Error('Method not implemented.'); } From e4fa1d34e57529f260fc33e5b693b01cc27782c1 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Tue, 23 Jun 2026 08:38:12 -0700 Subject: [PATCH 031/696] Wire up universal tool_instructions for agent host prompts (#322507) * Wire up universal tool_instructions for agent host prompts * Address review follow-ups for universal tool_instructions --- .../node/copilot/copilotAgentSession.ts | 4 +- .../node/copilot/copilotSessionLauncher.ts | 29 ++++- .../node/copilot/prompts/promptRegistry.ts | 67 +++++++++- .../node/copilot/prompts/systemMessage.ts | 25 ++++ .../node/copilot/prompts/toolInstructions.ts | 121 ++++++++++++++++++ .../test/node/agentHostPromptRegistry.test.ts | 39 +++++- .../test/node/toolInstructions.test.ts | 87 +++++++++++++ 7 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts create mode 100644 src/vs/platform/agentHost/test/node/toolInstructions.test.ts diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index a0c0a765f18ce..6db6f3d74a415 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -40,7 +40,7 @@ import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerVal import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import type { CopilotSessionLaunchPlan, IActiveClientSnapshot, ICopilotSessionLauncher, ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { ActiveClientState } from '../activeClientState.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; @@ -562,7 +562,7 @@ export class CopilotAgentSession extends Disposable { this._serverToolHost = options.serverToolHost; this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; - this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); + this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot); // Share the agent's live ActiveClientState when provided so clientId // changes are observed at stamp time. Standalone / test construction // seeds a private instance with the applied tools and no owning client. diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 97470ca6b711d..a28716b1e4e99 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -8,7 +8,7 @@ import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; -import { ILogService } from '../../../log/common/log.js'; +import { ILogService, LogLevel } from '../../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; @@ -23,6 +23,7 @@ import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfi import type { ITypedPermissionRequest } from './copilotToolDisplay.js'; import type { ICopilotPluginInfo } from './copilotAgent.js'; import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; +import { describeSystemMessageConfig } from './prompts/systemMessage.js'; import './prompts/allPrompts.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; @@ -64,6 +65,16 @@ export interface IActiveClientSnapshot { readonly mcpServers: AgentHostMcpServers; } +/** + * The set of client-tool names the agent sees for a snapshot — each tool's + * `ToolDefinition.name` (the camelCase `toolReferenceName`). Used both to gate + * tool-specific prompt sections at launch and to route client tool calls during + * the session, so the two stay derived from one definition. + */ +export function clientToolNamesFromSnapshot(snapshot: IActiveClientSnapshot): ReadonlySet { + return new Set(snapshot.tools.map(tool => tool.name)); +} + export interface ICopilotSessionRuntime { handlePermissionRequest(request: ITypedPermissionRequest): Promise; handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise; @@ -306,9 +317,23 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const skillDirectories = toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)); const instructionDirectories = toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)); const model = plan.kind === 'create' ? plan.model : plan.fallback.model; + // Client tools (browser tools, tasks, etc.) are addressed by the name the + // agent sees them under; used to gate tool-specific prompt sections. + const clientToolNames = clientToolNamesFromSnapshot(plan.snapshot); const promptContext: IAgentHostPromptContext = { getSetting: key => this._configurationService.getRootValue(agentHostCustomizationConfigSchema, key), + hasClientTool: name => clientToolNames.has(name), }; + // Resolved once per (re)launch — the SDK has no mid-session system-message + // update, so this reflects the model/tools/settings at launch time. Log a + // summary at info for prompt observability; the full config at trace. + const systemMessage = agentHostPromptRegistry.resolveSystemMessageConfig(model, promptContext); + this._logService.info(`[Copilot:${plan.sessionId}] Resolved system message: ${describeSystemMessageConfig(systemMessage)}`); + if (this._logService.getLevel() <= LogLevel.Trace) { + // Guarded: a `replace`-mode prompt's content can be multiple KB, so only + // serialize it when trace output is actually emitted. + this._logService.trace(`[Copilot:${plan.sessionId}] System message config: ${JSON.stringify(systemMessage, (_key, value) => typeof value === 'function' ? '[transform fn]' : value)}`); + } return { clientName: 'vscode', enableMcpApps: true, @@ -325,7 +350,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { customAgents, skillDirectories, instructionDirectories, - systemMessage: agentHostPromptRegistry.resolveSystemMessageConfig(model, promptContext), + systemMessage, pluginDirectories: coalesce(plugins.map(p => p.pluginDir)) .filter(d => d.scheme === Schemas.file).map(d => d.fsPath), tools: [...shellTools, ...runtime.createClientSdkTools(), ...runtime.createServerSdkTools()], diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts index 793cf5e11b181..60bc1d2117bab 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts @@ -8,6 +8,7 @@ import { agentHostCustomizationConfigSchema } from '../../../common/agentHostCus import type { SchemaValue } from '../../../common/agentHostSchema.js'; import type { ModelSelection } from '../../../common/state/protocol/state.js'; import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +import { resolveToolInstructionsOverride } from './toolInstructions.js'; type CustomizationConfigDefinition = typeof agentHostCustomizationConfigSchema.definition; @@ -27,6 +28,21 @@ export interface IAgentHostPromptContext { * {@link agentHostCustomizationConfigSchema}. */ getSetting(key: K): SchemaValue | undefined; + + /** + * Returns whether a *client* tool is available in the session, addressed by + * the camelCase `toolReferenceName` the agent sees it under (e.g. + * `openBrowserPage`). Used to gate tool-specific instructions on the tool + * being present, the agent-host equivalent of the Copilot extension + * inspecting its tool set. + * + * Scope: client tools only (the forwarded workbench tools). It does NOT see + * shell tools, server-SDK tools, or MCP-provided tools — those aren't in the + * session snapshot at launch (MCP is discovered dynamically). A line that + * gates on one of those names silently resolves to `false`; broadening this + * is the context-enrichment follow-up. + */ + hasClientTool(name: string): boolean; } /** @@ -109,7 +125,24 @@ export class AgentHostPromptRegistry { } /** - * Resolves the {@link SystemMessageConfig} for a session's model. + * Resolves the {@link SystemMessageConfig} for a session's model: the + * per-model (or default) config from {@link _resolveModelConfig}, with the + * model-agnostic section overrides from {@link _withUniversalSections} + * layered on top. + * + * Lifetime: the SDK accepts a system message only at session create/resume + * (there is no mid-session update), so this is resolved once per (re)launch + * and any tool-gated content reflects the tool set at that moment. A change + * to the session's tools/plugins is part of the launcher's restart-detection + * snapshot, so it re-launches the session and recomputes this; an in-flight + * turn keeps the prompt it launched with. + */ + resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { + return this._withUniversalSections(this._resolveModelConfig(model, context), context); + } + + /** + * Resolves the per-model config, before universal sections are layered on. * * Falls back to {@link COPILOT_AGENT_HOST_SYSTEM_MESSAGE} when the model is * unknown (e.g. server-side "Auto" selection where no model is chosen at @@ -117,7 +150,7 @@ export class AgentHostPromptRegistry { * contributor opts out for the current {@link context} (e.g. a setting that * gates it is disabled). */ - resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { + private _resolveModelConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { if (!model) { return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; } @@ -139,6 +172,36 @@ export class AgentHostPromptRegistry { } return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; } + + /** + * Layers section overrides that apply to EVERY model on top of the per-model + * (or default) config. Currently this is only the `tool_instructions` section + * (see {@link resolveToolInstructionsOverride}), which the agent host wants + * for all models rather than gating per-model like the Opus prompt. + * + * Only `customize`-mode configs carry section overrides, so this is a no-op + * for a contributor's full `replace` prompt (which owns the entire system + * message and intentionally drops the SDK foundation) and for `append` mode. + * A `replace` contributor that wants the universal guidance re-includes it + * itself by calling `appendUniversalToolInstructions` (in `toolInstructions.ts`) + * from its `resolveFullSystemPrompt`, mirroring how the extension's full-prompt + * models inline the same lines. + * + * A per-model `tool_instructions` override is composed with — not overwritten + * by — the universal lines (see {@link resolveToolInstructionsOverride}). + */ + private _withUniversalSections(config: SystemMessageConfig, context: IAgentHostPromptContext): SystemMessageConfig { + if (config.mode !== 'customize') { + return config; + } + const toolInstructions = resolveToolInstructionsOverride(name => context.hasClientTool(name), config.sections?.tool_instructions); + if (!toolInstructions) { + return config; + } + // Spread into a fresh object so the shared `COPILOT_AGENT_HOST_SYSTEM_MESSAGE` + // constant (returned by the fallback paths above) is never mutated. + return sectionOverrides({ ...config.sections, tool_instructions: toolInstructions }); + } } /** diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index 1b52c8cc6388f..ecd3fe02b673b 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -49,3 +49,28 @@ export function fullSystemPrompt(content: string): SystemMessageConfig { export function sectionOverrides(sections: Partial>): SystemMessageConfig { return { mode: 'customize', sections }; } + +/** + * One-line, log-friendly summary of a resolved {@link SystemMessageConfig} — + * the mode plus, for `customize`, which sections are overridden and with what + * action (e.g. `mode=customize sections=[identity:replace, tool_instructions:append]`). + * + * Keeps prompt observability cheap at `info` level without dumping full prompt + * text on every session launch (log the whole config at `trace` for that). + */ +export function describeSystemMessageConfig(config: SystemMessageConfig): string { + if (config.mode === 'replace') { + return `mode=replace (content length ${config.content.length})`; + } + if (config.mode === 'customize') { + const parts = Object.entries(config.sections ?? {}).map(([name, override]) => { + const action = override?.action; + return `${name}:${typeof action === 'function' ? 'transform' : action}`; + }); + // The customize convenience `content` is appended after all sections; note + // it so the summary doesn't understate what was sent. + const content = config.content ? ` +content(length ${config.content.length})` : ''; + return `mode=customize sections=[${parts.join(', ')}]${content}`; + } + return `mode=append (content length ${config.content?.length ?? 0})`; +} diff --git a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts new file mode 100644 index 0000000000000..957de4fcb0cdd --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.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 type { SectionOverride } from '@github/copilot-sdk'; +import { coalesce } from '../../../../../base/common/arrays.js'; + +/** + * Model-agnostic guidance for the `tool_instructions` system-prompt section. + * + * This is the agent-host home for the Copilot extension's `toolUseInstructions` + * pattern (`defaultAgentInstructions.tsx` and the per-model agent prompts): a + * sequence of one-line nudges, each gated on the relevant tool being present in + * the session, composed into the single SDK `tool_instructions` section. The + * agent host sees client tools under their camelCase `toolReferenceName`, so a + * line's gate and any tool name it mentions use that form (NOT the extension's + * snake_case ids). + * + * To add guidance for a tool, write a {@link ToolInstructionLine} and add it to + * {@link TOOL_INSTRUCTION_LINES}. For example, a browser line would gate on + * `openBrowserPage` (plus an agentic browser tool) being present and return a + * sentence such as "Use the browser tools (...) for front-end tasks". No lines + * are registered yet — concrete tool hookups land in follow-up changes; this + * module is the wiring they plug into. + */ + +/** + * A single gated tool-instructions line. Returns its content (a single + * sentence, no surrounding newlines) when the session exposes the tools it + * applies to, or `undefined` to contribute nothing. Mirrors one gated `<>…` + * fragment in the extension's `toolUseInstructions` block. + * + * @param hasTool predicate for whether a tool name is available in the session. + */ +type ToolInstructionLine = (hasTool: (name: string) => boolean) => string | undefined; + +/** + * The registered tool-instruction lines, in render order. Empty until concrete + * tool hookups are added — add new per-tool guidance here. + */ +const TOOL_INSTRUCTION_LINES: readonly ToolInstructionLine[] = []; + +/** + * Composes the applicable `lines` into a single block (one line each), or + * `undefined` when none apply to the session. + * + * @param lines defaults to the registered {@link TOOL_INSTRUCTION_LINES}; + * overridable so the composition can be exercised in isolation. + */ +export function universalToolInstructions(hasTool: (name: string) => boolean, lines: readonly ToolInstructionLine[] = TOOL_INSTRUCTION_LINES): string | undefined { + const rendered = coalesce(lines.map(line => line(hasTool))); + return rendered.length > 0 ? rendered.join('\n') : undefined; +} + +/** + * Folds universal tool-instructions `content` into a per-model contributor's + * `existing` `tool_instructions` override (if any), so a contributor's section + * is preserved rather than clobbered. + * + * @param existing the per-model contributor's `tool_instructions` override, if any. + */ +export function composeToolInstructions(existing: SectionOverride | undefined, content: string): SectionOverride { + // No per-model override: append after the SDK foundation section, led by a + // newline so it doesn't run on from the foundation content. + if (!existing) { + return { action: 'append', content: `\n${content}` }; + } + // A `remove` or transform-function override is a deliberate, non-composable + // choice by the contributor; preserve it untouched rather than fight it. + if (existing.action === 'remove' || typeof existing.action === 'function') { + return existing; + } + // Fold our lines into the contributor's content (preserve it, don't clobber), + // then pad relative to the foundation by where this action places the content: + // `append` sits after it (lead with a newline), `prepend` sits before it (trail + // with a newline), `replace` owns the section (no foundation adjacency, so no + // padding — and no leading newline even when the contributor's content is empty). + const base = existing.content ?? ''; + const merged = base ? `${base}\n${content}` : content; + switch (existing.action) { + case 'append': return { action: 'append', content: `\n${merged}` }; + case 'prepend': return { action: 'prepend', content: `${merged}\n` }; + default: return { action: existing.action, content: merged }; + } +} + +/** + * Resolves the `tool_instructions` {@link SectionOverride} for a session, + * composing the universal lines with any override a per-model contributor + * already set for that section. + * + * Returns `undefined` when no universal lines apply — the caller then keeps the + * contributor's `existing` override (if any) untouched. + * + * @param existing the per-model contributor's `tool_instructions` override, if any. + * @param lines defaults to the registered {@link TOOL_INSTRUCTION_LINES}. + */ +export function resolveToolInstructionsOverride(hasTool: (name: string) => boolean, existing: SectionOverride | undefined, lines: readonly ToolInstructionLine[] = TOOL_INSTRUCTION_LINES): SectionOverride | undefined { + const content = universalToolInstructions(hasTool, lines); + return content === undefined ? undefined : composeToolInstructions(existing, content); +} + +/** + * Appends the universal tool-instruction lines to a full (`replace`-mode) + * system prompt's `content`. + * + * A `replace`-mode contributor owns its whole prompt and so is skipped by the + * registry's universal `tool_instructions` layer. When such a contributor is + * added, calling this from its `resolveFullSystemPrompt` re-includes the same + * gated guidance (separated by a blank line), mirroring how the extension's + * full-prompt models inline it. No `resolveFullSystemPrompt` contributor exists + * yet, so this currently has no production caller. Returns `content` unchanged + * when no line applies. + * + * @param lines defaults to the registered {@link TOOL_INSTRUCTION_LINES}. + */ +export function appendUniversalToolInstructions(content: string, hasTool: (name: string) => boolean, lines: readonly ToolInstructionLine[] = TOOL_INSTRUCTION_LINES): string { + const extra = universalToolInstructions(hasTool, lines); + return extra === undefined ? content : `${content}\n\n${extra}`; +} diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index c2421fb6e5224..0f1c3c1168c8d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -13,9 +13,16 @@ import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/sy import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../node/copilot/prompts/allPrompts.js'; -/** Builds a prompt context backed by an in-memory bag of customization settings. */ -function context(settings: SchemaValues = {}): IAgentHostPromptContext { - return { getSetting: key => settings[key] }; +/** + * Builds a prompt context backed by an in-memory bag of customization settings + * and an optional set of available tool names. + */ +function context(settings: SchemaValues = {}, tools: readonly string[] = []): IAgentHostPromptContext { + const toolNames = new Set(tools); + return { + getSetting: key => settings[key], + hasClientTool: name => toolNames.has(name), + }; } suite('AgentHostPromptRegistry', () => { @@ -122,4 +129,30 @@ suite('AgentHostPromptRegistry', () => { assert.strictEqual(resolveOpus(true).mode, 'customize'); }); }); + + suite('universal tool instructions wiring', () => { + // No tool-instruction lines are registered yet (concrete tool hookups land + // in follow-up changes), so the universal layer is currently a no-op. These + // guard the wiring; the composition/gating itself is covered in + // toolInstructions.test.ts. + + test('is a no-op while no tool-instruction lines are registered', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + }); + + test('leaves a per-model tool_instructions override untouched', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['claude']; + resolveSectionOverrides(): Partial> { + return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, ['anyTool'])), + { mode: 'customize', sections: { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } } } + ); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts new file mode 100644 index 0000000000000..612b56d74769d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import type { SectionOverride } from '@github/copilot-sdk'; +import { appendUniversalToolInstructions, composeToolInstructions, resolveToolInstructionsOverride, universalToolInstructions } from '../../node/copilot/prompts/toolInstructions.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +/** Builds a `hasTool` predicate backed by the given available tool names. */ +function hasTools(...names: string[]): (name: string) => boolean { + const set = new Set(names); + return name => set.has(name); +} + +/** A gated tool-instruction line that renders `use ` when `tool` is present. */ +function lineFor(tool: string): (hasTool: (name: string) => boolean) => string | undefined { + return hasTool => hasTool(tool) ? `use ${tool}` : undefined; +} + +suite('toolInstructions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('universalToolInstructions', () => { + test('joins applicable lines in order and drops gated-out ones', () => { + assert.strictEqual(universalToolInstructions(hasTools('a', 'c'), [lineFor('a'), lineFor('b'), lineFor('c')]), 'use a\nuse c'); + }); + + test('returns undefined when no line applies (including the empty registry)', () => { + assert.strictEqual(universalToolInstructions(hasTools('x'), [lineFor('a')]), undefined); + assert.strictEqual(universalToolInstructions(hasTools('a')), undefined); + }); + }); + + suite('composeToolInstructions', () => { + test('appends to the foundation section when there is no per-model override', () => { + assert.deepStrictEqual(composeToolInstructions(undefined, 'LINE'), { action: 'append', content: '\nLINE' }); + }); + + test('folds into a per-model string override, preserving action and foundation spacing', () => { + const overrides: SectionOverride[] = [ + { action: 'append', content: 'A' }, // sits after foundation → leads with a newline + { action: 'prepend', content: 'P' }, // sits before foundation → trails with a newline + { action: 'replace', content: 'OWN' },// owns the section → no padding + { action: 'replace', content: '' }, // empty replace → no spurious leading newline + ]; + assert.deepStrictEqual(overrides.map(o => composeToolInstructions(o, 'LINE')), [ + { action: 'append', content: '\nA\nLINE' }, + { action: 'prepend', content: 'P\nLINE\n' }, + { action: 'replace', content: 'OWN\nLINE' }, + { action: 'replace', content: 'LINE' }, + ]); + }); + + test('preserves a remove or transform-function override untouched', () => { + const transform = (s: string) => s; + assert.deepStrictEqual(composeToolInstructions({ action: 'remove' }, 'LINE'), { action: 'remove' }); + assert.deepStrictEqual(composeToolInstructions({ action: transform }, 'LINE'), { action: transform }); + }); + }); + + suite('resolveToolInstructionsOverride', () => { + test('returns undefined (keep existing) when no line applies', () => { + const existing: SectionOverride = { action: 'append', content: 'A' }; + assert.strictEqual(resolveToolInstructionsOverride(hasTools('x'), existing, [lineFor('a')]), undefined); + }); + + test('composes the rendered lines with the existing override', () => { + assert.deepStrictEqual( + resolveToolInstructionsOverride(hasTools('a'), { action: 'append', content: 'A' }, [lineFor('a')]), + { action: 'append', content: '\nA\nuse a' } + ); + }); + }); + + suite('appendUniversalToolInstructions', () => { + test('returns the prompt unchanged while no lines apply (empty registry)', () => { + assert.strictEqual(appendUniversalToolInstructions('FULL PROMPT', hasTools('a')), 'FULL PROMPT'); + }); + + test('appends the rendered lines after a blank line when a line applies', () => { + assert.strictEqual(appendUniversalToolInstructions('FULL PROMPT', hasTools('a'), [lineFor('a')]), 'FULL PROMPT\n\nuse a'); + }); + }); +}); From 4c876b977401c6bb3d6eb0e1ebd50d45ae58ddae Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 23 Jun 2026 17:49:16 +0200 Subject: [PATCH 032/696] sessions: validate persisted new-session view state shape (#322546) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../layout/browser/sessionLayoutController.ts | 7 ++++- .../browser/sessionLayoutController.test.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts index f9e3e360ee31e..46be8a822f3a4 100644 --- a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts @@ -423,7 +423,12 @@ export class LayoutController extends Disposable { const newSessionRaw = this._storageService.get(NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); if (newSessionRaw) { try { - this._newSessionViewState = JSON.parse(newSessionRaw) as INewSessionViewState; + const parsed = JSON.parse(newSessionRaw); + if (parsed && typeof parsed.auxiliaryBarVisible === 'boolean') { + this._newSessionViewState = { auxiliaryBarVisible: parsed.auxiliaryBarVisible }; + } else { + this._storageService.remove(NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); + } } catch { this._storageService.remove(NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); } diff --git a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts index 50211adab7f65..89d29bca52d63 100644 --- a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts @@ -121,6 +121,7 @@ suite('LayoutController', () => { readonly workspaceFolders?: readonly { readonly uri: URI }[]; readonly layoutState?: readonly object[]; readonly newSessionViewState?: { readonly auxiliaryBarVisible: boolean }; + readonly newSessionViewStateRaw?: string; } function createLayoutController(options: ICreateOptions = {}): LayoutController { @@ -133,6 +134,9 @@ suite('LayoutController', () => { if (options.newSessionViewState) { storageService.store('sessions.newSessionViewState', JSON.stringify(options.newSessionViewState), StorageScope.WORKSPACE, 0); } + if (options.newSessionViewStateRaw !== undefined) { + storageService.store('sessions.newSessionViewState', options.newSessionViewStateRaw, StorageScope.WORKSPACE, 0); + } instaService.stub(IStorageService, storageService); const configService = new TestConfigurationService(); @@ -329,6 +333,28 @@ suite('LayoutController', () => { ); }); + test('ignores malformed persisted new-session state and does not force-hide the aux bar', () => { + // Persisted object is missing the `auxiliaryBarVisible` boolean. + createLayoutController({ newSessionViewStateRaw: JSON.stringify({ foo: 'bar' }) }); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled }); + + activeSessionObs.set(untitled, undefined); + + assert.ok( + !setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'malformed state must not force-hide the aux bar' + ); + assert.ok( + openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + 'should fall back to the default Files view' + ); + assert.strictEqual( + storageService.get('sessions.newSessionViewState', StorageScope.WORKSPACE), + undefined, + 'malformed state should be removed from storage' + ); + }); + test('does not open views when session has no workspace', () => { createLayoutController(); const session = makeSession(URI.parse('session:1'), { From dd66c66b153e595a39a8c301e00174b6017f0df6 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:56:33 -0700 Subject: [PATCH 033/696] Phase 16: editable Claude customization resolution via disk scan (#322501) * Phase 16: editable Claude customization resolution via disk scan Resolve a Claude session's customizations (agents, skills, slash commands, rules, MCP servers) by scanning the file system for the real customization files and shipping each item's real editable file: URI, instead of synthesizing stub files from the SDK query payloads. - Add disk-scan resolver + per-scope (Workspace/User) container mapping (customizations/claudeSessionCustomizationDiscovery.ts + scan/). - Add read-only built-in agents/skills tier (claudeBuiltinCommands.ts); curated pre-materialize, SDK-derived post-materialize. - Inline the projection into getSessionCustomizations; delete the synthetic-stub bundler and the standalone projector. - Watcher-backed live refresh of the customization list. - Update phase16-plan.md, roadmap.md, and CONTEXT.md to match. * Address PR review: depth-cap recursive rule scan, encode built-in URIs, fix roadmap bold markers * Fix claudeAgent integration test: register IFileService + INativeEnvironmentService ClaudeAgentSession now reads userHome at construction (Phase 16 watcher); the integration test's ServiceCollections lacked the env/file services, so session construction threw 'Cannot read properties of undefined (reading userHome)'. Add the same in-memory file + mock env services the unit harness uses. --- .../platform/agentHost/node/claude/CONTEXT.md | 33 +- .../node/claude/claudeAgentSession.ts | 105 ++--- .../customizations/claudeBuiltinCommands.ts | 182 ++++++++ .../claudeSdkCustomizationBundler.ts | 182 -------- .../claudeSessionCustomizationDiscovery.ts | 391 ++++++++++++++++++ .../claudeSessionCustomizationsProjector.ts | 39 -- .../scan/claudeAgentSkillScan.ts | 83 ++++ .../customizations/scan/claudeMcpScan.ts | 91 ++++ .../customizations/scan/claudeRuleScan.ts | 131 ++++++ .../agentHost/node/claude/phase15-plan.md | 232 +++++++++++ .../agentHost/node/claude/phase16-plan.md | 275 ++++++++++++ .../platform/agentHost/node/claude/roadmap.md | 276 ++++++++----- .../test/node/claudeAgent.integrationTest.ts | 26 ++ .../agentHost/test/node/claudeAgent.test.ts | 172 +++++++- .../claudeBuiltinCommands.test.ts | 102 +++++ .../claudeCustomizationTestUtils.ts | 36 ++ .../claudeSdkCustomizationBundler.test.ts | 169 -------- ...laudeSessionCustomizationDiscovery.test.ts | 265 ++++++++++++ ...audeSessionCustomizationsProjector.test.ts | 78 ---- .../scan/claudeAgentSkillScan.test.ts | 70 ++++ .../customizations/scan/claudeMcpScan.test.ts | 61 +++ .../scan/claudeRuleScan.test.ts | 78 ++++ .../agentPlugins/common/pluginParsers.ts | 16 +- .../test/common/pluginParsers.test.ts | 62 +++ 24 files changed, 2520 insertions(+), 635 deletions(-) create mode 100644 src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts delete mode 100644 src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts create mode 100644 src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts delete mode 100644 src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts create mode 100644 src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts create mode 100644 src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts create mode 100644 src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts create mode 100644 src/vs/platform/agentHost/node/claude/phase15-plan.md create mode 100644 src/vs/platform/agentHost/node/claude/phase16-plan.md create mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts create mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeCustomizationTestUtils.ts delete mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts create mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts create mode 100644 src/vs/platform/agentHost/test/node/customizations/scan/claudeAgentSkillScan.test.ts create mode 100644 src/vs/platform/agentHost/test/node/customizations/scan/claudeMcpScan.test.ts create mode 100644 src/vs/platform/agentHost/test/node/customizations/scan/claudeRuleScan.test.ts diff --git a/src/vs/platform/agentHost/node/claude/CONTEXT.md b/src/vs/platform/agentHost/node/claude/CONTEXT.md index ceb6c8f54bc88..1d590c76e272f 100644 --- a/src/vs/platform/agentHost/node/claude/CONTEXT.md +++ b/src/vs/platform/agentHost/node/claude/CONTEXT.md @@ -778,7 +778,38 @@ SDK; the host does not need to gate. | `onClientToolCallComplete(...)` | Host → SDK | Resolves the in-process MCP tool's pending promise | Same mechanism as `respondToUserInputRequest` | | `setCustomizationEnabled(uri, enabled)` | Host → SDK | `Query.reloadPlugins()` (runtime) | **Defer-and-coalesce** when busy: set `_pendingPluginReload`, drain at next yield. Idle path applies immediately. The SDK's `reloadPlugins` returns the refreshed `commands / agents / plugins / mcpServers` — useful as a verification probe but not required for correctness | | `getCustomizations()` | SDK → Host (projection) | `Query.supportedCommands()` / `supportedAgents()` / `mcpServerStatus()` | Compose the live snapshot from runtime SDK queries plus the host plugin manager's enabled set | -| `getSessionCustomizations(session)` | SDK → Host (projection) | Same SDK queries, scoped per-session | Per-session because each Query has its own loaded plugin set | +| `getSessionCustomizations(session)` | Disk scan (+ SDK filter) → Host (projection) | Disk scan of `~/.claude/**` + `/.claude/**`; live `Query.supportedCommands()` / `supportedAgents()` / `mcpServerStatus()` used only as a **filter** when materialized | **Phase 16.** See "Phase 16 — disk-scan customization resolution" below. Pre-materialize: client-pushed ∪ full disk scan + curated built-ins. Materialized: client-pushed ∪ (disk scan ∩ SDK-known) + SDK-only / built-in read-only entries | + +**Phase 16 — disk-scan customization resolution.** As shipped, +`getSessionCustomizations` does **not** project SDK query payloads into +editable items (those carry only name + description, no file path). Instead +it **scans the file system** for the real customization files and ships each +item's real editable `file:` `uri`: + +- **Scanners** (`customizations/scan/`): `claudeAgentSkillScan.ts` (agents + + skills; `.claude/commands/*.md` are folded into skills per the spec), + `claudeMcpScan.ts` (`settings.json` `mcpServers` block + flat `.mcp.json`), + `claudeRuleScan.ts` (CLAUDE.md + `.claude/rules/**`). Reuse the shared + `pluginParsers.ts` frontmatter/MCP parsers. +- **Builder** (`customizations/claudeSessionCustomizationDiscovery.ts`): + `buildDiscoveredCustomizations(...)` maps scanned entries to + `DirectoryCustomization` containers (one per (scope, kind) — Workspace vs + User) with real-file children + top-level `McpServerCustomization`. When a + live pipeline exists it intersects the disk set with the SDK-known set by + `(name, type)` and adds SDK-known-but-not-on-disk items as **non-editable** + `claude-internal:` entries. +- **Built-in tier** (`customizations/claudeBuiltinCommands.ts`): a curated set + of built-in slash commands (`CLAUDE_BUILTIN_COMMANDS`, 13) and agents + (`CLAUDE_BUILTIN_AGENTS`, 5) is surfaced read-only on the `agent-builtin:` + scheme pre-materialize, then superseded by the live SDK set + post-materialize. Built-ins are display-only (`contrib/chat` untouched). +- **Projection is inlined** in `ClaudeAgentSession.getSessionCustomizations` + (no separate projector function): client-pushed entries (with the per-id + enablement overlay) first, then the discovered + built-in tier appended. +- **Watcher** (`ClaudeCustomizationWatcher`): correlated watch on both + `.claude` roots (recursive) + `` narrowed to `.mcp.json`, debounced; + fires `onDidCustomizationsChange` so the list updates live. +- The synthetic-stub `ClaudeSdkCustomizationBundler` (Phase 11) is **deleted**. **Skills as plugins.** The SDK has no `Options.skills` field. A directory containing a `skills/` subfolder *is* a valid plugin from diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 21e6e9970e616..25fe525b31635 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -9,6 +9,8 @@ import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -31,8 +33,10 @@ import { convertToolCallResult } from './clientTools/claudeClientToolResult.js'; import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; import { SessionClientCustomizationsDiff } from './customizations/claudeSessionClientCustomizationsModel.js'; -import { projectSessionCustomizations } from './customizations/claudeSessionCustomizationsProjector.js'; -import { ClaudeSdkCustomizationBundler } from './customizations/claudeSdkCustomizationBundler.js'; +import { ClaudeCustomizationWatcher, buildDiscoveredCustomizations, resolveClaudeAgentName } from './customizations/claudeSessionCustomizationDiscovery.js'; +import { scanClaudeDiskCustomizations } from './customizations/scan/claudeAgentSkillScan.js'; +import { scanClaudeMcpServers } from './customizations/scan/claudeMcpScan.js'; +import { scanClaudeRules } from './customizations/scan/claudeRuleScan.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import { IClaudeProxyHandle } from './claudeProxyService.js'; import { ClaudeSdkPipeline, IRematerializer, type ISdkResolvedCustomizations } from './claudeSdkPipeline.js'; @@ -82,7 +86,6 @@ function resolveCurrentPermissionMode( export class ClaudeAgentSession extends Disposable { private _pipeline: ClaudeSdkPipeline | undefined; - private _sdkBundler: ClaudeSdkCustomizationBundler | undefined; /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */ private _provisionalModel: ModelSelection | undefined; @@ -257,6 +260,8 @@ export class ClaudeAgentSession extends Disposable { @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, ) { super(); this.project = project; @@ -266,6 +271,18 @@ export class ClaudeAgentSession extends Disposable { this.abortController = abortController; this.toolDiff = this._register(toolDiff); this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire())); + + // Watch the on-disk Claude customization sources so edits made outside + // the session (a new `~/.claude/agents/*.md`, an edited skill, a changed + // `.mcp.json`) drive a workbench re-fetch. Active from construction so + // it covers the provisional (pre-materialize) window too. + const customizationWatcher = this._register(new ClaudeCustomizationWatcher( + this.workingDirectory, + this._environmentService.userHome, + this._fileService, + this._logService, + )); + this._register(customizationWatcher.onDidChange(() => this._onDidCustomizationsChange.fire())); } /** @@ -291,6 +308,7 @@ export class ClaudeAgentSession extends Disposable { const permissionMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; const { mcpServers, allowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); + const agentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); const options = await buildOptions( { @@ -304,7 +322,7 @@ export class ClaudeAgentSession extends Disposable { mcpServers, allowedTools, plugins: this.clientCustomizationsDiff.consume(), - agent: this._resolveAgentName(this._provisionalAgent), + agent: agentName, }, ctx.proxyHandle, data => this._logService.error(`[Claude SDK stderr] ${data}`), @@ -340,15 +358,6 @@ export class ClaudeAgentSession extends Disposable { } this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithCredits(s)))); this._pipeline = pipeline; - // On-disk Open Plugin bundle for SDK-discovered customizations. - // The bundle directory is content-addressed by the SDK snapshot - // hash and lives under the plugin manager's user-data tree; - // disposing the bundler does NOT delete the on-disk tree (kept - // as a warm cache across sessions on the same workingDirectory). - this._sdkBundler = this._register(this._instantiationService.createInstance( - ClaudeSdkCustomizationBundler, - this.workingDirectory, - )); // Seed the pipeline's bijective config cache so a rebuild re-applies // the user's last-chosen model / effort without losing the picker @@ -389,6 +398,7 @@ export class ClaudeAgentSession extends Disposable { const liveMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; try { const { mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); + const rebuildAgentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); const rebuildAbort = new AbortController(); const rebuildOptions = await buildOptions( { @@ -402,7 +412,7 @@ export class ClaudeAgentSession extends Disposable { mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools, plugins: this.clientCustomizationsDiff.consume(), - agent: this._resolveAgentName(this._provisionalAgent), + agent: rebuildAgentName, }, ctx.proxyHandle, data => this._logService.error(`[Claude SDK stderr] ${data}`), @@ -625,33 +635,6 @@ export class ClaudeAgentSession extends Disposable { await this._metadataStore.write(this.sessionUri, { agent: agent ?? null }); } - /** - * Resolve an {@link AgentSelection} URI to the SDK agent name the - * SDK expects on `Options.agent`. Every custom agent the picker can - * surface for a Claude session comes from the SDK side - * ({@link ClaudeSdkCustomizationBundler} populates - * `SessionCustomization.agents` from `Query.supportedAgents()`), - * pointing at on-disk `.../agents/.md` files we wrote - * ourselves, so the name is the file basename. - * - * Returns `undefined` when no agent is selected (or the URI doesn't - * resolve to a known agent file) so the SDK falls back to its default - * (no `--agent` flag). - */ - private _resolveAgentName(agent: AgentSelection | undefined): string | undefined { - if (!agent) { - return undefined; - } - const uri = URI.parse(agent.uri); - const basename = uri.path.split('/').pop() ?? ''; - const name = basename.replace(/\.md$/i, ''); - if (!name) { - this._logService.warn(`[Claude:${this.sessionId}] _resolveAgentName: could not extract agent name from URI '${agent.uri}'`); - return undefined; - } - return name; - } - /** * Inject a steering message. Builds the `priority: 'now'` * {@link SDKUserMessage} and hands it to the pipeline; the pipeline @@ -853,23 +836,41 @@ export class ClaudeAgentSession extends Disposable { */ async getSessionCustomizations(): Promise { const { synced, enablement } = this.clientCustomizationsDiff.model.state.get(); - let bundled: Customization | undefined; - if (this._pipeline && this._sdkBundler) { - let sdk: ISdkResolvedCustomizations | undefined; + const userHome = this._environmentService.userHome; + const [discovered, rules, mcpServers] = await Promise.all([ + scanClaudeDiskCustomizations(this.workingDirectory, userHome, this._fileService), + scanClaudeRules(this.workingDirectory, userHome, this._fileService), + scanClaudeMcpServers(this.workingDirectory, userHome, this._fileService), + ]); + + // Post-materialize, the live SDK snapshot filters the disk set down to + // what the session actually loaded (and surfaces SDK-only items as + // non-editable). Pre-materialize there is no Query, so the full disk + // set is shown. A transient SDK read failure leaves `sdk` undefined, + // falling back to the unfiltered disk set rather than blanking the UI. + let sdk: ISdkResolvedCustomizations | undefined; + if (this._pipeline) { try { sdk = await this._pipeline.snapshotResolvedCustomizations(); } catch (err) { this._logService.warn(`[Claude:${this.sessionId}] snapshotResolvedCustomizations failed`, err); } - if (sdk) { - try { - bundled = await this._sdkBundler.bundle(sdk); - } catch (err) { - this._logService.warn(`[Claude:${this.sessionId}] SDK bundle failed`, err); - } - } } - return projectSessionCustomizations(synced, enablement, bundled); + + // `buildDiscoveredCustomizations` also folds in the read-only "Built-in" + // surfacing (curated pre-materialize, SDK-derived post-materialize) for + // both agents and skills, so the SDK-vs-curated decision lives in one place. + const discoveredCustomizations = buildDiscoveredCustomizations([...discovered, ...rules], mcpServers, this.workingDirectory, userHome, sdk); + + // Final projection: the client-pushed tier (with the per-id enablement + // overlay) first, then the discovered tier appended verbatim — the + // enablement map is deliberately NOT applied to discovered entries. + const result: Customization[] = synced.map(item => ({ + ...item.customization, + enabled: enablement.get(item.customization.id) ?? item.customization.enabled, + })); + result.push(...discoveredCustomizations); + return result; } // #endregion diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts new file mode 100644 index 0000000000000..54967763c6b8c --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { CustomizationType } from '../../../common/state/protocol/channels-session/state.js'; +import { CustomizationLoadStatus, customizationId, type DirectoryCustomization, type SkillCustomization } from '../../../common/state/sessionState.js'; + +/** + * URI scheme for synthetic "built-in" customizations that have no editable + * file on disk. These entries appear in the customization list purely for + * discovery (their name and description); they carry no openable content. + */ +const AGENT_BUILTIN_SCHEME = 'agent-builtin'; + +/** + * A Claude built-in slash command backed by the Skill tool, used to seed the + * **pre-materialize** built-in list. These ship compiled into the Claude + * CLI/SDK — they have no editable file on disk and, before a live session + * exists, the SDK can't tell us its real command set, so we show a curated + * best-guess list for discoverability. + * + * Once a session materializes, {@link buildSdkBuiltinSkillsContainer} replaces + * this list with the runtime's actual built-ins (the SDK commands we don't + * discover on disk), so this list only matters pre-session and may safely + * drift from the CLI over time. + * + * This list covers the Skill-tool built-ins only. The CLI-level built-ins + * typed directly in the terminal (`/help`, `/clear`, `/compact`, `/config`, + * `/fast`, `/model`, `/tasks`, `/workflows`) are intentionally excluded — + * they are not skills and would be mislabeled in a Skills container. + */ +interface IClaudeBuiltinCommand { + readonly name: string; + /** + * User-facing description, resolved lazily so `localize()` runs at call + * time rather than module-init (which would freeze the bundle locale). + */ + readonly description: () => string; +} + +const CLAUDE_BUILTIN_COMMANDS: readonly IClaudeBuiltinCommand[] = [ + { name: 'init', description: () => localize('claude.builtin.init', "(Built-In) Scan the codebase and generate a `CLAUDE.md` file with project structure, conventions, and instructions for future sessions.") }, + { name: 'review', description: () => localize('claude.builtin.review', "(Built-In) Review a pull request or set of changes.") }, + { name: 'security-review', description: () => localize('claude.builtin.securityReview', "(Built-In) Complete a security review of the pending changes on the current branch.") }, + { name: 'code-review', description: () => localize('claude.builtin.codeReview', "(Built-In) Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at a chosen effort level (low→max). Pass `--comment` to post findings as inline PR comments, or `--fix` to apply them to the working tree.") }, + { name: 'simplify', description: () => localize('claude.builtin.simplify', "(Built-In) Review changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it doesn't hunt for bugs (use `/code-review` for that).") }, + { name: 'verify', description: () => localize('claude.builtin.verify', "(Built-In) Run the app and observe behavior to confirm a code change actually does what it's supposed to. Use to verify a PR, confirm a fix, or validate local changes before pushing.") }, + { name: 'run', description: () => localize('claude.builtin.run', "(Built-In) Launch and drive the project's app to see a change working — run, start, or screenshot the app, or confirm a change works in the real app (not just tests).") }, + { name: 'loop', description: () => localize('claude.builtin.loop', "(Built-In) Run a prompt or slash command on a recurring interval (e.g. `/loop 5m /foo`, defaults to 10m). For recurring tasks or polling status — not one-off work.") }, + { name: 'claude-api', description: () => localize('claude.builtin.claudeApi', "(Built-In) Reference for the Claude API / Anthropic SDK: model IDs, pricing, params, streaming, tool use, MCP, agents, caching, token counting, migration.") }, + { name: 'fewer-permission-prompts', description: () => localize('claude.builtin.fewerPermissionPrompts', "(Built-In) Scan transcripts for common read-only Bash/MCP calls and add a prioritized allowlist to project `.claude/settings.json` to reduce permission prompts.") }, + { name: 'update-config', description: () => localize('claude.builtin.updateConfig', "(Built-In) Configure the Claude Code harness via `settings.json`: hooks for automated behaviors, permissions, env vars, and hook troubleshooting.") }, + { name: 'keybindings-help', description: () => localize('claude.builtin.keybindingsHelp', "(Built-In) Customize keyboard shortcuts, rebind keys, add chord bindings, or modify `~/.claude/keybindings.json`.") }, + { name: 'write-a-skill', description: () => localize('claude.builtin.writeASkill', "(Built-In) Author a new skill.") }, +]; + +/** + * A Claude built-in subagent, used to seed the **pre-materialize** agent list. + * These ship compiled into the Claude CLI/SDK (no editable file on disk), so + * before a live session exists we surface a curated best-guess set for + * discovery and selection. Once a session materializes, the live + * `supportedAgents()` set supersedes this (see the SDK fallback in + * `buildDiscoveredCustomizations`), so the list may safely drift over time. + * + * Includes the SDK default (`general-purpose`) for completeness; the discovery + * layer hides it (selecting it is equivalent to "no selection"). The model + * each agent runs on is folded into the description since the customization + * surface has no model field. + */ +export interface IClaudeBuiltinAgent { + readonly name: string; + /** User-facing description, resolved lazily (see {@link IClaudeBuiltinCommand.description}). */ + readonly description: () => string; +} + +export const CLAUDE_BUILTIN_AGENTS: readonly IClaudeBuiltinAgent[] = [ + { name: 'claude', description: () => localize('claude.builtinAgent.claude', "(Built-In) Catch-all for any task that doesn't fit a more specific agent — the default when no agent name is typed.") }, + { name: 'claude-code-guide', description: () => localize('claude.builtinAgent.claudeCodeGuide', "(Built-In) Answers questions about the Claude Agent SDK, and the Claude/Anthropic API — features, hooks, slash commands, MCP servers, settings, IDE integrations, SDK agent-building, and API usage. Model: Haiku.") }, + { name: 'Explore', description: () => localize('claude.builtinAgent.explore', "(Built-In) Read-only search agent for broad fan-out searches across many files when you only need the conclusion; it locates code rather than reviewing it. Model: Haiku.") }, + { name: 'general-purpose', description: () => localize('claude.builtinAgent.generalPurpose', "(Built-In) General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks.") }, + { name: 'Plan', description: () => localize('claude.builtinAgent.plan', "(Built-In) Software architect agent for designing implementation plans — step-by-step plans, critical files, and architectural trade-offs.") }, +]; + +/** + * A resolved built-in skill entry ready to become a read-only customization. + * Structurally matches the SDK's `SlashCommand` (`{ name, description }`), so + * the live command set can be passed straight through. + */ +interface IBuiltinSkillEntry { + readonly name: string; + readonly description: string; +} + +/** + * Builds the read-only "Built-in" skills container from resolved + * `{ name, description }` entries. Each child is a {@link CustomizationType.Skill} + * on the {@link AGENT_BUILTIN_SCHEME}; the name and description shown in the + * list are the discovery information it carries (the entries have no openable + * content). Returns `undefined` when there are no entries. + */ +function buildBuiltinSkillsContainer(entries: readonly IBuiltinSkillEntry[]): DirectoryCustomization | undefined { + if (entries.length === 0) { + return undefined; + } + + const children: SkillCustomization[] = entries.map(entry => { + const uri = URI.from({ scheme: AGENT_BUILTIN_SCHEME, path: `/skill/${encodeURIComponent(entry.name)}` }).toString(); + return { + type: CustomizationType.Skill, + id: customizationId(uri), + uri, + name: entry.name, + description: entry.description, + }; + }); + + const containerUri = URI.from({ scheme: AGENT_BUILTIN_SCHEME, path: '/skills' }).toString(); + return { + type: CustomizationType.Directory, + id: customizationId(containerUri), + uri: containerUri, + name: 'builtin', + enabled: true, + contents: CustomizationType.Skill, + writable: false, + load: { kind: CustomizationLoadStatus.Loaded }, + children, + }; +} + +/** + * The curated, hardcoded built-in container. Used ONLY pre-materialize — + * before a live SDK snapshot exists, this is our best guess at the runtime's + * built-in slash commands so the user can still discover them. Once a session + * materializes, {@link buildSdkBuiltinSkillsContainer} supersedes it with the + * runtime's real command set. + * + * Curated commands that collide with a discovered disk skill are excluded so + * a user's editable `/` is never duplicated by a read-only built-in + * (mirrors {@link buildSdkBuiltinSkillsContainer} and the built-in-agent path). + * Returns `undefined` when nothing remains. + * + * @param diskSkillNames Names of skills discovered on disk, to exclude. + */ +export function buildClaudeBuiltinSkillsContainer(diskSkillNames: ReadonlySet): DirectoryCustomization | undefined { + return buildBuiltinSkillsContainer( + CLAUDE_BUILTIN_COMMANDS + .filter(cmd => !diskSkillNames.has(cmd.name)) + .map(cmd => ({ name: cmd.name, description: cmd.description() })) + ); +} + +/** + * The post-materialize built-in container, derived from the live SDK command + * set. A command the SDK reports but that we do NOT discover on disk as an + * editable skill is a genuine runtime built-in (it has no editable file), so + * it is surfaced read-only via {@link AGENT_BUILTIN_SCHEME} using the SDK's + * own description. Commands backed by a discovered disk skill are excluded — + * they are already shown as their editable selves. This auto-includes runtime + * built-ins we never hardcoded and self-heals as the CLI evolves. + * + * @param commands The live SDK command set (`supportedCommands()`). + * @param diskSkillNames Names of skills discovered on disk, to exclude. + */ +export function buildSdkBuiltinSkillsContainer( + commands: readonly IBuiltinSkillEntry[], + diskSkillNames: ReadonlySet, +): DirectoryCustomization | undefined { + const seen = new Set(); + const entries: IBuiltinSkillEntry[] = []; + for (const command of commands) { + if (diskSkillNames.has(command.name) || seen.has(command.name)) { + continue; + } + seen.add(command.name); + entries.push({ name: command.name, description: command.description }); + } + return buildBuiltinSkillsContainer(entries); +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts deleted file mode 100644 index 050b8ef4ac326..0000000000000 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts +++ /dev/null @@ -1,182 +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 { VSBuffer } from '../../../../../base/common/buffer.js'; -import { hash } from '../../../../../base/common/hash.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { IFileService } from '../../../../files/common/files.js'; -import { IAgentPluginManager } from '../../../common/agentPluginManager.js'; -import { CustomizationLoadStatus, CustomizationType, customizationId, type AgentCustomization, type PluginCustomization, type SkillCustomization } from '../../../common/state/sessionState.js'; -import type { ISdkResolvedCustomizations } from '../claudeSdkPipeline.js'; - -const PLUGIN_NAME = 'claude-discovered'; -const DISPLAY_NAME = localize('claude.discovered.displayName', "Discovered in Claude"); -const DISCOVERED_DIR = 'claude-discovered'; - -/** - * The Claude SDK's built-in default agent. Hidden from the picker: - * selecting it would be equivalent to "no selection" since the SDK - * uses it as the fallback when `Options.agent` is omitted. - */ -export const CLAUDE_SDK_DEFAULT_AGENT_NAME = 'general-purpose'; - -/** - * Bundles the Claude SDK's currently-resolved customization view - * (commands + agents from `Query.supportedCommands()` / - * `supportedAgents()` / `mcpServerStatus()`) into a synthetic on-disk - * [Open Plugin](https://open-plugins.com/) layout, so the workbench's - * plugin expander can scan it and emit per-type child items - * (`PromptsType.agent` / `PromptsType.skill` / `PromptsType.prompt`). - * - * Returns a single {@link Customization} (plugin container) whose `name` - * is `"Discovered in Claude"` and whose URI points at the on-disk bundle - * root. The `children` array is populated directly from the SDK snapshot - * so the agent picker can list Claude-native agents and skills without - * waiting on filesystem expansion. - * - * The directory is namespaced by a hash of the working directory so - * concurrent sessions on different folders don't collide. Repeated - * {@link bundle} calls with the same SDK snapshot reuse the prior - * bundle (nonce match) and skip the rewrite. - */ -export class ClaudeSdkCustomizationBundler extends Disposable { - - private readonly _rootUri: URI; - private _lastNonce: string | undefined; - - constructor( - workingDirectory: URI, - @IFileService private readonly _fileService: IFileService, - @IAgentPluginManager pluginManager: IAgentPluginManager, - ) { - super(); - const authority = `claude-${hash(workingDirectory.toString())}`; - this._rootUri = URI.joinPath(pluginManager.basePath, DISCOVERED_DIR, authority); - } - - async bundle(snapshot: ISdkResolvedCustomizations): Promise { - if (snapshot.commands.length === 0 && snapshot.agents.length === 0) { - return undefined; - } - - const hashParts: string[] = []; - for (const agent of snapshot.agents) { - hashParts.push(`agent:${agent.name}\n${agent.description}\n${agent.model ?? ''}`); - } - for (const cmd of snapshot.commands) { - hashParts.push(`command:${cmd.name}\n${cmd.description}\n${cmd.argumentHint ?? ''}`); - } - hashParts.sort(); - const nonce = String(hash(hashParts.join('\n'))); - - if (this._lastNonce !== nonce) { - try { - await this._fileService.del(this._rootUri, { recursive: true }); - } catch { - // First bundle — directory may not exist. - } - // Vendor-neutral manifest path per Open Plugins spec - // (`.plugin/plugin.json`). `name` is the only required field - // and must be lowercase alphanumeric / `-` / `.` only. - const manifestUri = URI.joinPath(this._rootUri, '.plugin', 'plugin.json'); - await this._fileService.writeFile(manifestUri, VSBuffer.fromString(JSON.stringify({ - name: PLUGIN_NAME, - description: 'Customizations discovered by the Claude agent', - }, null, '\t'))); - - for (const agent of snapshot.agents) { - const fileUri = URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`); - await this._fileService.writeFile(fileUri, VSBuffer.fromString(agentMarkdown(agent.name, agent.description))); - } - for (const cmd of snapshot.commands) { - // Treat Claude slash commands as skills: each becomes its - // own `skills//SKILL.md` subdirectory per the Agent - // Skills format. Conceptually they're the same thing — - // a named, model-invocable capability — and the workbench - // buckets them under skills. - const dirName = safeName(cmd.name); - const fileUri = URI.joinPath(this._rootUri, 'skills', dirName, 'SKILL.md'); - await this._fileService.writeFile(fileUri, VSBuffer.fromString(skillMarkdown(dirName, cmd.description, cmd.argumentHint))); - } - this._lastNonce = nonce; - } - - // Hide the SDK's built-in default agent — see - // {@link CLAUDE_SDK_DEFAULT_AGENT_NAME} for the full rationale. - // `uri` is the on-disk path of the file we just wrote — the - // workbench's customization harness reads it via `parseNew` to - // hydrate `ICustomAgent`, so a synthetic identity scheme would - // fail to parse and the agents would never reach the picker. - const agentChildren: AgentCustomization[] = snapshot.agents - .filter(agent => agent.name !== CLAUDE_SDK_DEFAULT_AGENT_NAME) - .map(agent => { - const agentUri = URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`).toString(); - return { - type: CustomizationType.Agent, - id: customizationId(agentUri), - uri: agentUri, - name: agent.name, - description: agent.description, - }; - }); - const skillChildren: SkillCustomization[] = snapshot.commands.map(cmd => { - const dirName = safeName(cmd.name); - const skillUri = URI.joinPath(this._rootUri, 'skills', dirName, 'SKILL.md').toString(); - return { - type: CustomizationType.Skill, - id: customizationId(skillUri), - uri: skillUri, - name: dirName, - description: cmd.description, - }; - }); - - const rootUriString = this._rootUri.toString(); - return { - type: CustomizationType.Plugin, - id: customizationId(rootUriString), - uri: rootUriString, - name: DISPLAY_NAME, - enabled: true, - load: { kind: CustomizationLoadStatus.Loaded }, - children: [...agentChildren, ...skillChildren], - }; - } -} - -function safeName(name: string): string { - return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'unnamed'; -} - -/** - * Open Plugins agent frontmatter: `name` (1-64 chars, kebab-case) and - * `description` (max 1024 chars). The body is the agent's system - * prompt; the SDK doesn't surface it, so we leave the body empty. - */ -function agentMarkdown(name: string, description: string): string { - return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n`; -} - -/** - * Agent Skills `SKILL.md` frontmatter: `name` (MUST match the - * containing directory name) and `description`. The SDK's - * `argumentHint` is rendered as a `$ARGUMENTS` usage hint in the body. - */ -function skillMarkdown(name: string, description: string, argumentHint: string | undefined): string { - const body = argumentHint ? `\nUsage: \`${argumentHint}\`\n` : ''; - return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n${body}`; -} - -function yamlString(s: string): string { - // Quote always; escape backslashes and double quotes. Single-line: drop newlines. - const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, ' '); - return `"${escaped}"`; -} - -function truncate(s: string, max: number): string { - return s.length <= max ? s : `${s.slice(0, max - 1)}…`; -} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts new file mode 100644 index 0000000000000..2f917ccdb8839 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts @@ -0,0 +1,391 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { isEqualOrParent } from '../../../../../base/common/resources.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { makeMcpServerCustomization, parseAgentFile, toParsedAgent, type IParsedAgent, type IParsedRule, type IParsedSkill } from '../../../../agentPlugins/common/pluginParsers.js'; +import { CustomizationType, type AgentSelection, type McpServerCustomization } from '../../../common/state/protocol/channels-session/state.js'; +import { CustomizationLoadStatus, customizationId, type AgentCustomization, type Customization, type DirectoryCustomization, type RuleCustomization, type SkillCustomization } from '../../../common/state/sessionState.js'; +import type { ISdkResolvedCustomizations } from '../claudeSdkPipeline.js'; +import { deriveMcpState } from './scan/claudeMcpScan.js'; +import { claudeMemoryFiles } from './scan/claudeRuleScan.js'; +import { CLAUDE_BUILTIN_AGENTS, buildClaudeBuiltinSkillsContainer, buildSdkBuiltinSkillsContainer } from './claudeBuiltinCommands.js'; + +/** + * The Claude SDK's built-in default agent. Hidden from the picker: + * selecting it would be equivalent to "no selection" since the SDK + * uses it as the fallback when `Options.agent` is omitted. + */ +export const CLAUDE_SDK_DEFAULT_AGENT_NAME = 'general-purpose'; + +/** + * Scheme for synthetic, non-openable URIs that mark SDK-only customizations + * the disk scan couldn't locate (Decision D2). It has no file provider, so + * the workbench renders such entries read-only. The writer ({@link nonEditableUri}) + * and reader ({@link resolveClaudeAgentName}) share this constant so the two + * never drift. + */ +const CLAUDE_INTERNAL_SCHEME = 'claude-internal'; + +function makeDirectory(base: URI, sub: string, contents: CustomizationType.Agent | CustomizationType.Skill | CustomizationType.Rule, children: readonly (AgentCustomization | SkillCustomization | RuleCustomization)[]): DirectoryCustomization { + const uri = URI.joinPath(base, '.claude', sub).toString(); + return { + type: CustomizationType.Directory, + id: customizationId(uri), + uri, + name: sub, + enabled: true, + contents, + writable: true, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [...children], + }; +} + +/** + * The scope a discovered customization belongs to, derived from which + * `.claude/` tree contains its source file. + */ +const enum ClaudeCustomizationScope { + Workspace = 'workspace', + User = 'user', +} + +/** + * Attributes a discovered file to the scope whose `.claude/` directory + * contains it. SDK-only (`claude-internal:`) and any out-of-tree URIs fall + * back to the user scope. Drives per-scope grouping so the workbench can + * label containers "Workspace" vs "User". + */ +function scopeOf(uri: URI, workingDirectory: URI | undefined): ClaudeCustomizationScope { + return workingDirectory && uri.scheme === workingDirectory.scheme && isEqualOrParent(uri, workingDirectory) + ? ClaudeCustomizationScope.Workspace + : ClaudeCustomizationScope.User; +} + +/** + * Maps the disk-discovered customizations into the protocol + * {@link Customization} surface. Agents, skills and rules are wrapped in + * {@link DirectoryCustomization} containers (the protocol's `Customization` + * union has no bare agent/skill/rule member), one container per (scope, kind): + * the container `uri` is the real `/.claude/` directory so the + * workbench derives the "Workspace" vs "User" label from it (mirroring + * CopilotAgent). Each child carries its real source-file `uri` so the + * workbench can open it for editing. MCP servers are top-level entries. + */ +export function mapDiscoveredCustomizations( + discovered: readonly (IParsedAgent | IParsedSkill | IParsedRule)[], + mcpServers: readonly McpServerCustomization[], + workingDirectory: URI | undefined, + userHome: URI, +): readonly Customization[] { + const buckets = new Map([ + [ClaudeCustomizationScope.Workspace, { agents: [], skills: [], rules: [] }], + [ClaudeCustomizationScope.User, { agents: [], skills: [], rules: [] }], + ]); + for (const d of discovered) { + const bucket = buckets.get(scopeOf(d.uri, workingDirectory))!; + if (d.customization.type === CustomizationType.Agent) { + bucket.agents.push(d.customization); + } else if (d.customization.type === CustomizationType.Skill) { + bucket.skills.push(d.customization); + } else { + bucket.rules.push(d.customization); + } + } + + const result: Customization[] = []; + // Workspace containers first (precedence), then user. `base` is the scope + // root the container `.claude/` uri is built from. + const orderedScopes: readonly (readonly [ClaudeCustomizationScope, URI | undefined])[] = [ + [ClaudeCustomizationScope.Workspace, workingDirectory], + [ClaudeCustomizationScope.User, userHome], + ]; + for (const [scope, base] of orderedScopes) { + if (!base) { + continue; + } + const bucket = buckets.get(scope)!; + if (bucket.agents.length > 0) { + result.push(makeDirectory(base, 'agents', CustomizationType.Agent, bucket.agents)); + } + if (bucket.skills.length > 0) { + result.push(makeDirectory(base, 'skills', CustomizationType.Skill, bucket.skills)); + } + if (bucket.rules.length > 0) { + result.push(makeDirectory(base, 'rules', CustomizationType.Rule, bucket.rules)); + } + } + + result.push(...mcpServers); + return result; +} + +/** + * A synthetic, non-openable URI that marks an SDK-only customization the + * disk scan couldn't locate. The `claude-internal:` scheme has no file + * provider, so the workbench renders the entry read-only (Decision D2). + */ +function nonEditableUri(kind: string, name: string): URI { + return URI.from({ scheme: CLAUDE_INTERNAL_SCHEME, path: `/${kind}/${encodeURIComponent(name)}` }); +} + +/** + * Resolves an {@link AgentSelection} URI to the SDK agent name the SDK + * expects on `Options.agent`. {@link AgentSelection} carries only a `uri`, + * so the name is recovered from the source: + * + * - A `claude-internal:` URI — an SDK-only agent the disk scan couldn't + * locate (Decision D2); the name is the path segment encoded by + * {@link nonEditableUri} (this is its inverse). + * - A real `file:` agent — the SDK keys agents by their frontmatter + * `name`, which may differ from the filename, so it is parsed (falling + * back to the basename when the file can't be read). + * + * Returns `undefined` when no agent is selected (or the name can't be + * recovered) so the SDK falls back to its default (no `--agent` flag). + */ +export async function resolveClaudeAgentName( + agent: AgentSelection | undefined, + fileService: IFileService, + logService: ILogService, + sessionId: string, +): Promise { + if (!agent) { + return undefined; + } + const uri = URI.parse(agent.uri); + + // SDK-only (non-editable) agents encode the name in the path: + // `claude-internal:/agent/` (inverse of nonEditableUri). + if (uri.scheme === CLAUDE_INTERNAL_SCHEME) { + const last = uri.path.split('/').pop() ?? ''; + const name = last ? decodeURIComponent(last) : ''; + if (!name) { + logService.warn(`[Claude:${sessionId}] resolveClaudeAgentName: could not extract agent name from URI '${agent.uri}'`); + return undefined; + } + return name; + } + + // Real on-disk agent: the SDK identifies it by its frontmatter `name`, + // which the filename need not match. + try { + const parsed = await parseAgentFile(uri, fileService); + if (parsed.name) { + return parsed.name; + } + } catch (err) { + logService.warn(`[Claude:${sessionId}] resolveClaudeAgentName: failed to parse agent file '${agent.uri}', falling back to basename`, err); + } + + const basename = uri.path.split('/').pop() ?? ''; + const name = basename.replace(/\.md$/i, ''); + if (!name) { + logService.warn(`[Claude:${sessionId}] resolveClaudeAgentName: could not extract agent name from URI '${agent.uri}'`); + return undefined; + } + return name; +} + +/** + * Builds the discovered-customization projection for a session, applying + * the live SDK snapshot as a post-materialize filter. + * + * - `sdk === undefined` (provisional): the full disk-discovered set is + * returned unfiltered — no live session yet to say what's active. + * - `sdk` present (materialized): disk entries are kept only when the live + * session knows them (matched by name, per type — agents against the SDK + * agent set; skills against the SDK command set; MCP against the SDK + * server set, enriched with live state). SDK-known AGENTS and MCP servers + * with no matching disk file are surfaced as NON-EDITABLE entries + * (`claude-internal:` — Decision D2): a non-editable agent is still + * selectable and a non-editable MCP server still shows status. SDK-only + * SKILLS (Claude's built-in slash commands like `/init`) are NOT mixed in + * among the editable disk skills — instead they appear, read-only, in the + * separate "Built-in" skills container this function appends. The SDK's + * built-in default agent is hidden. Rules (CLAUDE.md + `.claude/rules`) + * have no SDK counterpart and are always kept. + * + * The "Built-in" surfacing for BOTH agents and skills is decided here (the + * single place that has the disk set and the optional `sdk` snapshot): built-in + * agents merge into the agent set (selectable, `claude-internal:`); built-in + * skills are a separate read-only container appended to the result. + */ +export function buildDiscoveredCustomizations( + discovered: readonly (IParsedAgent | IParsedSkill | IParsedRule)[], + mcpServers: readonly McpServerCustomization[], + workingDirectory: URI | undefined, + userHome: URI, + sdk: ISdkResolvedCustomizations | undefined, +): readonly Customization[] { + // The read-only "Built-in" skills container: pre-materialize the curated + // list, post-materialize the live SDK command set minus the disk skills. + // Appended to whichever projection is returned below so the SDK-vs-curated + // decision for built-in skills sits next to the one for built-in agents. + const diskSkillNames = new Set( + discovered.filter(d => d.customization.type === CustomizationType.Skill).map(d => d.name) + ); + const builtinSkills = sdk + ? buildSdkBuiltinSkillsContainer(sdk.commands, diskSkillNames) + : buildClaudeBuiltinSkillsContainer(diskSkillNames); + const withBuiltinSkills = (list: readonly Customization[]): readonly Customization[] => + builtinSkills ? [...list, builtinSkills] : list; + + if (!sdk) { + // Pre-materialize there is no live agent set, so seed the curated + // built-in agents alongside the disk agents. They use the same + // non-editable `claude-internal:` shape the SDK fallback produces + // post-materialize (selectable, name round-trips), so the same agent + // looks identical before and after materialize. A disk agent of the + // same name wins; the SDK default agent is hidden. + const diskAgentNames = new Set( + discovered.filter(d => d.customization.type === CustomizationType.Agent).map(d => d.name) + ); + const builtinAgents = CLAUDE_BUILTIN_AGENTS + .filter(a => a.name !== CLAUDE_SDK_DEFAULT_AGENT_NAME && !diskAgentNames.has(a.name)) + .map(a => toParsedAgent({ uri: nonEditableUri('agent', a.name), name: a.name, description: a.description() })); + return withBuiltinSkills(mapDiscoveredCustomizations([...discovered, ...builtinAgents], mcpServers, workingDirectory, userHome)); + } + + const agentNames = new Set(sdk.agents.map(a => a.name)); + const commandNames = new Set(sdk.commands.map(c => c.name)); + const mcpByName = new Map(sdk.mcpServers.map(s => [s.name, s] as const)); + + // Keep disk entries the live session actually loaded. A loaded skill + // surfaces in the SDK's `supportedCommands()` set, so disk skills are + // matched against `commandNames`. + const seenAgents = new Set(); + const entries: (IParsedAgent | IParsedSkill | IParsedRule)[] = []; + for (const d of discovered) { + if (d.customization.type === CustomizationType.Agent) { + // Hide the SDK's built-in default agent even when a same-named + // file exists on disk — selecting it is equivalent to "no + // selection", so it must never reach the picker post-materialize. + if (d.name === CLAUDE_SDK_DEFAULT_AGENT_NAME) { + continue; + } + if (agentNames.has(d.name)) { + entries.push(d); + seenAgents.add(d.name); + } + } else if (d.customization.type === CustomizationType.Skill) { + if (commandNames.has(d.name)) { + entries.push(d); + } + } else { + // Rules (CLAUDE.md + `.claude/rules`) have no SDK counterpart, so + // they are never filtered — always keep them. + entries.push(d); + } + } + + // SDK-known-but-not-on-disk AGENTS → non-editable fallback (Decision D2): + // still selectable as the session agent even without an editable file. + // (Skills get no such fallback — see the doc comment: a non-openable + // skill entry is only ever a dead link.) + for (const agent of sdk.agents) { + if (agent.name === CLAUDE_SDK_DEFAULT_AGENT_NAME || seenAgents.has(agent.name)) { + continue; + } + entries.push(toParsedAgent({ uri: nonEditableUri('agent', agent.name), name: agent.name, ...(agent.description ? { description: agent.description } : {}) })); + } + + // MCP: keep disk servers the SDK loaded (enriched with live state); add + // SDK-only servers as non-editable entries (status is still informative). + const seenMcp = new Set(); + const servers: McpServerCustomization[] = []; + for (const server of mcpServers) { + const sdkServer = mcpByName.get(server.name); + if (!sdkServer) { + continue; + } + seenMcp.add(server.name); + servers.push({ ...server, state: deriveMcpState(sdkServer.status) }); + } + for (const [name, sdkServer] of mcpByName) { + if (seenMcp.has(name)) { + continue; + } + servers.push({ ...makeMcpServerCustomization(nonEditableUri('mcp', name), name), state: deriveMcpState(sdkServer.status) }); + } + + return withBuiltinSkills(mapDiscoveredCustomizations(entries, servers, workingDirectory, userHome)); +} + +/** + * Watches a session's on-disk Claude customization sources and fires + * {@link onDidChange} (debounced) whenever any of them is created, edited, + * or removed, so the workbench re-fetches `getSessionCustomizations`. + * + * Watched roots: + * - `/.claude` and `/.claude` (recursive) — cover the + * agents / skills / commands trees, the `.claude/rules` + `.claude/CLAUDE.md` + * instruction sources, plus the inline `settings.json` MCP config. + * - `` (non-recursive) — watched to catch the sibling `.mcp.json` and + * the root `CLAUDE.md` / `CLAUDE.local.md` memory files; the triggers are + * narrowed to those files so unrelated edits in the workspace root don't + * force a re-scan. + */ +export class ClaudeCustomizationWatcher extends Disposable { + + private static readonly DEBOUNCE_MS = 300; + + readonly onDidChange: Event; + + constructor( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, + logService: ILogService, + debounceMs: number = ClaudeCustomizationWatcher.DEBOUNCE_MS, + ) { + super(); + + // URIs whose subtree (or exact file, for `.mcp.json`) signals a re-scan. + const triggers: URI[] = []; + const watch = (uri: URI, recursive: boolean) => { + try { + this._register(fileService.watch(uri, { recursive, excludes: [] })); + } catch (err) { + logService.warn(`[ClaudeCustomizationWatcher] failed to watch '${uri.toString()}': ${err instanceof Error ? err.message : String(err)}`); + } + }; + + if (workingDirectory) { + const projectClaude = URI.joinPath(workingDirectory, '.claude'); + watch(projectClaude, true); + triggers.push(projectClaude); + watch(workingDirectory, false); + triggers.push(URI.joinPath(workingDirectory, '.mcp.json')); + } + const userClaude = URI.joinPath(userHome, '.claude'); + watch(userClaude, true); + triggers.push(userClaude); + + // Memory files (CLAUDE.md / CLAUDE.local.md) — reuse the scanner's + // canonical list so the watcher never drifts from what it actually + // reads. Entries already under a recursively-watched `.claude` root + // (e.g. `.claude/CLAUDE.md`) are harmless duplicate triggers. + triggers.push(...claudeMemoryFiles(workingDirectory, userHome)); + + // Collapse the raw file-change stream into a single debounced signal. + // The `DisposableStore` argument is required because `onDidChange` is a + // public property (see the `Event.debounce` leak-safety note). + this.onDidChange = Event.signal(Event.debounce( + Event.filter(fileService.onDidFilesChange, e => triggers.some(t => e.affects(t)), this._store), + (_last, e) => e, + debounceMs, + undefined, + undefined, + undefined, + this._store, + )); + } +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts deleted file mode 100644 index a5682d59bc773..0000000000000 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts +++ /dev/null @@ -1,39 +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 type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; -import type { Customization } from '../../../common/state/protocol/state.js'; - -/** - * Project the union of (a) client-pushed customizations and - * (b) the on-disk discovery bundle (server-provided) onto the - * protocol's {@link Customization} surface. - * - * Client-pushed entries get the per-id enablement overlay applied - * (`enablement.get(id) ?? customization.enabled`). The discovery - * bundle is surfaced verbatim — it is a single synthetic plugin URI - * pointing at an on-disk Open Plugin layout (`agents/`, `skills/`, - * `commands/`, `rules/`) the workbench's plugin expander scans to - * emit per-type child items. Per-file enablement happens - * workbench-side; we surface only the bundle URI. - */ -export function projectSessionCustomizations( - synced: readonly ISyncedCustomization[], - enablement: ReadonlyMap, - discovered: Customization | undefined, -): readonly Customization[] { - const result: Customization[] = []; - - for (const item of synced) { - const enabled = enablement.get(item.customization.id) ?? item.customization.enabled; - result.push({ ...item.customization, enabled }); - } - - if (discovered) { - result.push(discovered); - } - - return result; -} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts new file mode 100644 index 0000000000000..8973d345624fc --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { readAgentComponents, readSkills, toParsedAgent, toParsedSkill, type INamedPluginResource, type IParsedAgent, type IParsedSkill } from '../../../../../agentPlugins/common/pluginParsers.js'; + +/** + * The `.claude/` directories one scope (project or user) contributes + * customizations from. `commands` holds slash commands, which are a + * variant of skills (folded into the skill set, skills winning conflicts). + */ +function scopeRoots(scope: URI): { readonly agents: URI; readonly skills: URI; readonly commands: URI } { + const base = URI.joinPath(scope, '.claude'); + return { + agents: URI.joinPath(base, 'agents'), + skills: URI.joinPath(base, 'skills'), + commands: URI.joinPath(base, 'commands'), + }; +} + +/** + * Merges parsed components into a name→component map. First-seen wins, so + * scanning project before user (and skills before commands) makes the + * earlier entry shadow same-named later ones deterministically. + */ +function collectByName(into: Map, items: readonly T[]): void { + for (const item of items) { + if (!into.has(item.name)) { + into.set(item.name, item); + } + } +} + +/** + * Scans a Claude session's `.claude/{agents,skills,commands}` directories + * (project + user scope) and returns the discovered customizations with + * their real source-file URIs and parsed names/descriptions. + * + * Reuses the shared parsers in `pluginParsers.ts`: + * - agents — flat `.md` files, frontmatter name/description. + * - skills — `/SKILL.md` layout, frontmatter name/description. + * - commands — flat `.md` files; a variant of skills, so they are + * folded into the skill set (skills win on a name conflict). + * + * Each scope is scanned independently and merged project-before-user so + * precedence is deterministic (the shared readers parallelize/sort + * internally, so handing them both scopes at once would NOT guarantee + * which scope wins on a name clash). Within a scope, skills are collected + * before commands so skills win same-name conflicts. MCP servers are + * resolved separately (they live inline in `settings.json` / `.mcp.json`, + * not as directory entries). + */ +export async function scanClaudeDiskCustomizations( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, +): Promise { + // Project scope first so it wins precedence over user scope. + const scopes = workingDirectory ? [workingDirectory, userHome] : [userHome]; + const agents = new Map(); + const skills = new Map(); + + for (const scope of scopes) { + const { agents: agentsDir, skills: skillsDir, commands: commandsDir } = scopeRoots(scope); + const [agentRes, skillRes, commandRes] = await Promise.all([ + readAgentComponents([agentsDir], fileService), + // pluginRoot = the skills dir itself, so the readSkills fallback + // targets `/SKILL.md` (a legit single-skill dir), never + // an unrelated `/SKILL.md`. + readSkills(skillsDir, [skillsDir], fileService), + readAgentComponents([commandsDir], fileService), + ]); + collectByName(agents, agentRes.map(toParsedAgent)); + // Skills before commands so a same-named skill wins (spec section 3). + collectByName(skills, skillRes.map(toParsedSkill)); + collectByName(skills, commandRes.map(toParsedSkill)); + } + + return [...agents.values(), ...skills.values()]; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts new file mode 100644 index 0000000000000..da51e6cbf4a66 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { makeMcpServerCustomization, readJsonFile } from '../../../../../agentPlugins/common/pluginParsers.js'; +import { McpServerStatus, type McpServerCustomization, type McpServerState } from '../../../../common/state/protocol/channels-session/state.js'; + +/** + * The JSON files a Claude session reads MCP server declarations from, + * in precedence order (project before user — first-seen wins). + */ +function claudeMcpFiles(workingDirectory: URI | undefined, userHome: URI): URI[] { + const files: URI[] = []; + if (workingDirectory) { + files.push(URI.joinPath(workingDirectory, '.claude', 'settings.json')); + files.push(URI.joinPath(workingDirectory, '.mcp.json')); + } + files.push(URI.joinPath(userHome, '.claude', 'settings.json')); + return files; +} + +/** + * Extracts the `{ name: config }` MCP server map from one parsed JSON + * file. `settings.json` carries many unrelated top-level keys, so we + * ONLY treat its explicit `mcpServers` block as servers. A bare + * `.mcp.json` MAY instead be a flat `{ name: config }` map. + */ +function extractMcpServerMap(uri: URI, raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + const obj = raw as Record; + if (Object.hasOwn(obj, 'mcpServers')) { + const servers = obj.mcpServers; + return (servers && typeof servers === 'object' && !Array.isArray(servers)) ? servers as Record : undefined; + } + return uri.path.endsWith('.mcp.json') ? obj : undefined; +} + +/** + * Scans a Claude session's `settings.json` / `.mcp.json` files (project + + * user scope) for declared MCP servers and returns them as + * {@link McpServerCustomization} entries pointing at the real source file. + * + * Pre-materialize, the state is {@link McpServerStatus.Starting} (declared + * but not yet connected). Post-materialize, the live connection status is + * enriched from the SDK's `mcpServerStatus()` by the projector. + */ +export async function scanClaudeMcpServers( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, +): Promise { + const seen = new Set(); + const result: McpServerCustomization[] = []; + for (const uri of claudeMcpFiles(workingDirectory, userHome)) { + const raw = await readJsonFile(uri, fileService); + const servers = extractMcpServerMap(uri, raw); + if (!servers) { + continue; + } + for (const [name, config] of Object.entries(servers)) { + if (!config || typeof config !== 'object' || Array.isArray(config) || seen.has(name)) { + continue; + } + seen.add(name); + result.push(makeMcpServerCustomization(uri, name)); + } + } + result.sort((a, b) => a.name.localeCompare(b.name)); + return result; +} + +/** + * Maps an SDK MCP connection status onto the protocol MCP state. Precise + * `Error` / `AuthRequired` states (which carry extra payload) are deferred; + * anything not clearly `connected` / `disabled` reports as `Starting`. + */ +export function deriveMcpState(status: string): McpServerState { + switch (status) { + case 'connected': + return { kind: McpServerStatus.Ready }; + case 'disabled': + return { kind: McpServerStatus.Stopped }; + default: + return { kind: McpServerStatus.Starting }; + } +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts new file mode 100644 index 0000000000000..ca07c707e58fb --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { basename } from '../../../../../../base/common/resources.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { parseRuleFile, pathExists, type IParsedRule } from '../../../../../agentPlugins/common/pluginParsers.js'; +import { CustomizationType } from '../../../../common/state/protocol/channels-session/state.js'; +import { customizationId, type RuleCustomization } from '../../../../common/state/sessionState.js'; + +/** + * The CLAUDE.md memory files a session loads as always-on rules. The + * documented primary locations only — user `~/.claude/CLAUDE.md`, project + * `./CLAUDE.md` / `./.claude/CLAUDE.md`, and the personal `./CLAUDE.local.md`. + * (Managed-policy OS-level CLAUDE.md and the ancestor-directory walk are + * intentionally out of scope.) + * + * Exported so the change-watcher can reuse the exact same list and never + * drift from what {@link scanClaudeRules} actually reads. + */ +export function claudeMemoryFiles(workingDirectory: URI | undefined, userHome: URI): URI[] { + const files = [URI.joinPath(userHome, '.claude', 'CLAUDE.md')]; + if (workingDirectory) { + files.push( + URI.joinPath(workingDirectory, 'CLAUDE.md'), + URI.joinPath(workingDirectory, '.claude', 'CLAUDE.md'), + URI.joinPath(workingDirectory, 'CLAUDE.local.md'), + ); + } + return files; +} + +/** + * Recursively collects `*.md` files under `dir` (sorted by path). Follows + * symlinked subdirectories — `.claude/rules` supports them — with a + * visited-set guard plus a hard depth cap so circular symlinks terminate. + * (The visited-set alone is insufficient: a symlink cycle like + * `rules/a -> rules` produces an ever-deeper, never-repeating path, so the + * depth cap is the real termination guarantee.) + */ +const MAX_RULE_SCAN_DEPTH = 32; + +async function readMarkdownFilesRecursive(dir: URI, fileService: IFileService, seen = new Set(), depth = 0): Promise { + const key = dir.toString(); + if (depth > MAX_RULE_SCAN_DEPTH || seen.has(key)) { + return []; + } + seen.add(key); + + let stat; + try { + stat = await fileService.resolve(dir); + } catch { + return []; + } + if (!stat.isDirectory || !stat.children) { + return []; + } + + const files: URI[] = []; + for (const child of stat.children) { + if (child.isDirectory) { + files.push(...await readMarkdownFilesRecursive(child.resource, fileService, seen, depth + 1)); + } else if (child.isFile && child.resource.path.toLowerCase().endsWith('.md')) { + files.push(child.resource); + } + } + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * Scans a session's Claude "memory" rules — the {@link CustomizationType.Rule} + * tier — from both forms: + * - CLAUDE.md instruction files ({@link claudeMemoryFiles}): always-on, + * no path scoping. + * - `.claude/rules/**\/*.md` (project + user, recursive): path-scoped when + * a `paths` frontmatter glob is present (parsed via {@link parseRuleFile}), + * otherwise always-on. + * + * Each rule carries its real source-file `uri` so the workbench can open it + * for editing. Rules are additive (not deduped by name) — the SDK has no + * rule concept, so they are never filtered post-materialize. + */ +export async function scanClaudeRules( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, +): Promise { + const result: IParsedRule[] = []; + + // CLAUDE.md memory files — always-on, no path scoping, no frontmatter. + for (const uri of claudeMemoryFiles(workingDirectory, userHome)) { + if (await pathExists(uri, fileService)) { + const ruleUri = uri.toString(); + const name = basename(uri); + const customization: RuleCustomization = { + type: CustomizationType.Rule, + id: customizationId(ruleUri), + uri: ruleUri, + name, + alwaysApply: true, + }; + result.push({ uri, name, customization }); + } + } + + // `.claude/rules/**\/*.md` — path-scoped when `paths` frontmatter present. + const scopes = workingDirectory ? [workingDirectory, userHome] : [userHome]; + for (const scope of scopes) { + const files = await readMarkdownFilesRecursive(URI.joinPath(scope, '.claude', 'rules'), fileService); + for (const uri of files) { + const parsed = await parseRuleFile(uri, fileService); + const ruleUri = uri.toString(); + const hasGlobs = !!parsed.globs?.length; + const customization: RuleCustomization = { + type: CustomizationType.Rule, + id: customizationId(ruleUri), + uri: ruleUri, + name: parsed.name, + ...(parsed.description ? { description: parsed.description } : {}), + ...(hasGlobs ? { globs: parsed.globs } : {}), + alwaysApply: !hasGlobs, + }; + result.push({ uri, name: parsed.name, ...(parsed.description ? { description: parsed.description } : {}), customization }); + } + } + + return result; +} diff --git a/src/vs/platform/agentHost/node/claude/phase15-plan.md b/src/vs/platform/agentHost/node/claude/phase15-plan.md new file mode 100644 index 0000000000000..8fb753af0034c --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase15-plan.md @@ -0,0 +1,232 @@ +# Phase 15 — SDK Distribution via `product.json` + main.vscode-cdn.net + +> **Retrospective**, not a forward plan. Phase 15 shipped without a +> dedicated `phaseN-plan.md`; this file documents what actually landed so +> the roadmap's Phase 15 link resolves and future readers have the same +> contract-level record the other phases carry. Source of truth for the +> code is the files cited inline. +> Last updated: 2026-06-17. + +**Status:** ✅ done — runtime downloader and the per-platform build +pipeline both landed. Unit tests green (16 across `resolveSdkTarget` + +`AgentSdkDownloader` in +[`agentSdkDownloader.test.ts`](../../test/node/agentSdkDownloader.test.ts)), +`typecheck-client` clean. Shipped under the previously-tracked +"per-platform" work (the earlier roadmap referenced a +`phase15-per-platform-plan.md` that was never written — this file replaces +those dangling links). + +## Goal + +Ship the Claude and Codex agent SDKs to end users without bundling them in +the app and without asking users to hand-install anything. A fresh +Insiders install should be able to pick a Claude or Codex model and have +the SDK fetched on demand, while developers keep a zero-friction local +override. + +The constraint that shaped the whole design: **macOS Universal bundles +share a single `product.json` across arm64 and x64 launches**, so the +shipped config cannot hard-code a per-arch URL. The runtime must resolve +the right tarball per launch. + +## Scope + +**In scope** +- `product.agentSdks.` config shape (`{ version, urlTemplate }`) in + [`product.ts`](../../../../base/common/product.ts). +- Runtime downloader `AgentSdkDownloader` + `resolveSdkTarget` in + [`agentSdkDownloader.ts`](../agentSdkDownloader.ts): dev-override → + cache → CDN download, with negative-cache latching and in-process + download de-duplication. +- Per-package strategy object `IAgentSdkPackage` so the downloader never + branches on provider id (`ClaudeSdkPackage`, `CodexSdkPackage` live in + their owning agent modules). +- Provider-registration gate via `IAgentSdkDownloader.isAvailable(pkg)`. +- Three-tier SDK resolution in + [`claudeAgentSdkService.ts`](./claudeAgentSdkService.ts#L186) `_loadSdk` + (env override → downloader → dev bare-import) and the Codex equivalent. +- Build pipeline under [`build/agent-sdk/`](../../../../../../build/agent-sdk/README.md): + per-SDK pinned `{package.json, package-lock.json}`, `produce.ts`, + `package.ts` (deterministic tar), `upload.ts` (idempotent CDN publish), + `common.ts` (shared helpers incl. `readAgentSdkResults`). +- Azure Pipelines integration: + [`agent-sdk-produce.yml`](../../../../../../build/azure-pipelines/common/agent-sdk-produce.yml) + before each `gulp vscode---min-ci`, and the + `packageTask` `jsonEditor` stamp in + [`gulpfile.vscode.ts`](../../../../../../build/gulpfile.vscode.ts#L308). + +**Out of scope** +- Per-tarball sha256 in `product.json` — replaced by `product.checksums` + (covers the shipped `product.json`) + HTTPS to a Microsoft-controlled + CDN as the trust chain. +- REH-web `agentSdks` stamping — the agent host is node-only; the + browser-served server has no consumer. +- A separate `AgentSDK` pipeline stage / `aggregate.ts` — rejected in + favour of an inline `readAgentSdkResults()` call inside the existing + `jsonEditor` callback, keeping `packageTask` a sync stream-returning + function. +- A standalone `verify-determinism.ts` CI gate — the determinism comes + from `npm ci` against the committed lockfile + reproducible + node-tar/gzip flags in `package.ts`; no separate enforcement script + shipped. + +## What shipped + +### Runtime config shape + +`product.agentSdks` is an optional map keyed by package id +([`product.ts`](../../../../base/common/product.ts#L81)): + +```typescript +interface IAgentSdkProductConfig { + readonly version: string; + readonly urlTemplate: string; // format2() template, e.g. + // https://main.vscode-cdn.net/agent-sdk/claude/0.3.169/{sdkTarget}.tgz +} +``` + +`urlTemplate` carries a single recognised placeholder, `{sdkTarget}`. The +same template ships on every platform; the runtime substitutes the +placeholder per launch. `vscode-distro` and OSS `product.json` both omit +`agentSdks` — the build IS the distribution, so only built products carry +it. + +### `resolveSdkTarget` — per-launch target resolution + +[`resolveSdkTarget(pkg, host?)`](../agentSdkDownloader.ts) maps the +running host `(platform, arch, libc)` to the build's tarball suffix: + +- Claude on glibc Linux → `linux-x64` / `linux-arm64` +- Claude on musl Linux → `linux-x64-musl` / `linux-arm64-musl` +- Codex Linux (any libc) → `linux-x64` / `linux-arm64` (its Linux binary + is statically musl-linked, so one SKU runs on both) +- everywhere else → `-` +- unsupported (`armhf`, web, …) → `undefined` → provider not registered + +The only per-SDK knob is `IAgentSdkPackage.hasSeparateMuslLinuxPackage` +(Claude: `true`, Codex: `false`). This runtime function is the deliberate +mirror of the build-time `getSdkTargetForBuild` in +[`build/agent-sdk/common.ts`](../../../../../../build/agent-sdk/common.ts) — +the two tables must stay in lockstep when a new SKU is added. + +### `AgentSdkDownloader` — resolution order + resilience + +[`loadSdkRoot(pkg, token)`](../agentSdkDownloader.ts) returns the absolute +SDK root (the dir containing `node_modules/`); callers resolve the +package-internal entrypoint themselves: + +1. **Dev override** — `process.env[pkg.devOverrideEnvVar]` returned + unchanged. +2. **Cache hit** — `/agent-host/sdk-cache////` + with a `.complete` sentinel. The `sdkTarget` segment keeps Universal + launches that resolve to different targets from thrashing one cache. +3. **Download** — fetch `format2(config.urlTemplate, { sdkTarget })`, + extract, write the sentinel. + +Resilience details that shipped: +- **Negative-cache latch** (`LOAD_FAILURE_NEGATIVE_CACHE_MS = 30s`, + keyed by `pkg.id`) so a broken CDN isn't hammered by poll-driven UIs. + Cancellations are not latched. +- **In-flight de-dup** keyed by `cacheDir` so concurrent `loadSdkRoot` + calls share one download; distinct targets get distinct entries. +- **Rename-winner** publish so concurrent first-launch downloads don't + corrupt the cache; the loser returns the winner's published dir. + +`isAvailable(pkg)` is the cheap synchronous startup gate: `true` iff the +dev override is set, OR (`product.agentSdks?.[pkg.id]` populated AND +`resolveSdkTarget(pkg)` resolves). Used to decide whether to register the +provider at all — no download triggered. + +### `_loadSdk` three-tier resolution + +[`claudeAgentSdkService.ts:186`](./claudeAgentSdkService.ts#L186): + +1. `AgentHostClaudeSdkRootEnvVar` override → import + `/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`. +2. `this._downloader.isAvailable(ClaudeSdkPackage)` → download root, + import the same entrypoint off it. Errors propagate as-is so a CDN + outage / corrupt cache surfaces actionable diagnostics, not a + misleading "cannot find module". +3. Dev bare-import `@anthropic-ai/claude-agent-sdk` (resolves via this + repo's `node_modules` devDependency) — reached only in dev launches. + +Codex mirrors this in `CodexAgent._startConnection`, resolving +`@openai/codex/bin/codex.js` off the root. + +### Build pipeline (`build/agent-sdk/`) + +- **`agents//{package.json, package-lock.json}`** — one folder per + SDK; the folder set IS the SDK list (no parallel array). Each + `package.json` declares exactly one dependency at an exact version + (no `^`/`~` — ranges would break the content-addressed CDN upload). + Today: Claude `0.3.169`, Codex `0.135.0`. +- **`package.ts` `buildOne`** — runs on any OS: `npm ci` with + `npm_config_libc/os/cpu` fetches the foreign-platform binary verbatim + from the locked graph, then reproducible node-tar+gzip. +- **`upload.ts` `uploadOne`** — HEAD-then-decide: absent → upload; + matching sha → skip (idempotent re-runs); drifted sha → fail loud + rather than overwrite content-addressed history. +- **`produce.ts`** — per-`(vscode-platform, arch)` entry. Iterates SDKs + in parallel, builds + (conditionally) uploads, writes the results JSON, + emits `##vso[task.setvariable variable=AGENT_SDK_RESULTS_FILE]`. + Behaviour splits on `VSCODE_PUBLISH`: `true` → build+upload+stamp; + unset → build-only (tarballs published as inspection artifacts, + `product.json` ships without `agentSdks`, runtime falls back to the + dev-override path — same UX as a local `gulp` build). +- **`gulpfile.vscode.ts` `packageTask`** — the existing `jsonEditor` + callback (the one that injects `commit`/`date`/`checksums`/`version`) + calls `readAgentSdkResults()` and, when non-empty, merges + `json.agentSdks`. REH writes it only for `type === 'reh'`. + +## Trade-offs accepted + +- **`urlTemplate` + `{sdkTarget}` over per-platform `{url, sha256}`.** + The original design stamped a concrete URL + hash per platform. That + breaks macOS Universal, where one `product.json` serves two arches. + Moving target resolution to launch time (and dropping the per-tarball + sha for `product.checksums` + HTTPS) is what makes Universal work with + a single shipped config. +- **No per-tarball sha256 verification at runtime.** Trust rests on the + signed app bundle covering `product.json` via `product.checksums`, plus + HTTPS to a Microsoft-controlled CDN. Accepted as equivalent to how the + rest of `product.json`'s URLs are trusted. +- **Inline gulp stamping over a dedicated pipeline stage.** Keeps + `packageTask` synchronous and avoids an `aggregate.ts`; the cost is + that the `produce.ts` ↔ `getSdkTargetForBuild` ↔ runtime + `resolveSdkTarget` triple must be kept in sync by convention. +- **Dev override retained indefinitely.** `chat.agentHost.claudeAgent.path` + → `AgentHostClaudeSdkRootEnvVar` (and the Codex equivalent) survive as + the SDK-development bypass; they are no longer the primary distribution + path. + +## Tests + +- [`agentSdkDownloader.test.ts`](../../test/node/agentSdkDownloader.test.ts) + — 16 tests: `resolveSdkTarget` table (platform/arch/musl/unsupported), + `isAvailable` gating, `loadSdkRoot` dev-override / template-substitution + / cache-miss-download / cache-hit / Universal-target-separation / + missing-config error / bad-placeholder error / cancellation cleanup / + concurrent-share / rename-loser. +- Build-side determinism is enforced structurally (committed lockfile + + reproducible tar flags); no separate determinism test script shipped. + +## Exit criteria (met) + +- Fresh Insiders install can use Claude/Codex without installing the SDK + or setting any path. +- SDK version bumps are a build-pipeline change: edit the exact version in + `build/agent-sdk/agents//package.json`, refresh the lockfile + (`npm install --package-lock-only --ignore-scripts`), commit both. The + per-platform `produce.ts` step republishes the tarballs and + `packageTask` re-stamps `product.agentSdks[pkg]`. +- Dev override keeps working for SDK development. + +## Bumping an SDK version (operational note) + +1. Edit `dependencies` version in + `build/agent-sdk/agents//package.json` to the new exact version. +2. From that directory: + `npm install --package-lock-only --ignore-scripts` to refresh + `package-lock.json`. +3. Commit both files. The next publish build produces + uploads the new + tarballs and stamps the new version into `product.json`. diff --git a/src/vs/platform/agentHost/node/claude/phase16-plan.md b/src/vs/platform/agentHost/node/claude/phase16-plan.md new file mode 100644 index 0000000000000..a0211c6456fca --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase16-plan.md @@ -0,0 +1,275 @@ +# Phase 16 — Editable Customization Resolution via Disk Scan + +> Generated by super-planner. Source: [roadmap.md](./roadmap.md) (phase 16, redesigned 2026-06-17). +> Council: GPT-5.5, Claude Opus 4.6, GPT-5.3-Codex (3-model consensus). +> Supersedes the original "eager session materialization at create time" plan, which was retired — see roadmap Phase 16. + +**Status:** implemented + +## Goal + +Resolve a Claude session's customizations (agents, skills, slash commands, MCP servers) by **scanning the file system for the real customization files** and shipping each item's **real editable `uri`** + parsed name/description — instead of trusting the SDK query APIs (which return only name+description, no path, no content). The live SDK list (`supportedCommands` / `supportedAgents` / `mcpServerStatus`) becomes a **post-materialize filter**, not the data source. Result: opening a discovered customization opens the user's real `~/.claude/agents/foo.md`, not a generated stub. + +## Scope + +**In scope** +- A Claude customization **disk-scan resolver** (Claude-specific module reusing the shared parsers in [pluginParsers.ts](../../../agentPlugins/common/pluginParsers.ts)), covering Claude's roots (scoped by session `cwd` + user home, honoring `settingSources: ['user','project','local']`): + - agents — `/.claude/agents/**`, `~/.claude/agents/**` + - skills — `/.claude/skills/**`, `~/.claude/skills/**` + - slash commands — `/.claude/commands/**`, `~/.claude/commands/**` (flat `.md`) + - rules (memory / instructions) — CLAUDE.md files (`~/.claude/CLAUDE.md`, `/CLAUDE.md`, `/.claude/CLAUDE.md`, `/CLAUDE.local.md`) + `.claude/rules/**/*.md` (project + user, recursive). CLAUDE.md → always-on `Rule`; `.claude/rules` files are path-scoped when a `paths` frontmatter glob is present (`alwaysApply` derived as `!globs`). + - MCP servers — `~/.claude/settings.json`, `/.claude/settings.json`, `/.mcp.json` +- Each resolved item carries a real `file:` `uri` + parsed name/description, projected as `DirectoryCustomization` containers with child customizations (agents/skills/rules) and top-level `McpServerCustomization` entries. Rules have no SDK counterpart, so they bypass the post-materialize filter (always kept). +- `getSessionCustomizations` two-mode behavior: + - **provisional (no pipeline):** client-pushed ∪ full disk scan (no filter). + - **materialized (live pipeline):** client-pushed ∪ (disk scan ∩ SDK-known set, name-matched per type). +- **Delete the synthetic-stub `ClaudeSdkCustomizationBundler` entirely** (Decision D2). The SDK-only non-editable fallback (items the live SDK reports that the disk scan couldn't locate) is generated **declaratively inline** in `buildDiscoveredCustomizations` via `claude-internal:` URIs — no stub files are ever written to disk. +- **Read-only "Built-in" surfacing** (beyond the original plan): curated built-in slash commands (13) and built-in agents (5) are surfaced as non-editable entries on the `agent-builtin:` scheme pre-materialize, then replaced by the SDK's real `supportedCommands()` / `supportedAgents()` set post-materialize. Lives in `node/claude/customizations/claudeBuiltinCommands.ts`; each description is prefixed `(Built-In) `. +- Watcher-backed refresh so the customization list updates live on disk changes (fires `onDidCustomizationsChange`). + +**Out of scope** +- **Any lifecycle change.** `createSession` stays provisional + cold; no eager/warm materialization, no `_sessionSequencer` change, no `onDidMaterializeSession` rework, no JSONL-write-timing work. (The retired original Phase 16 owned those — explicitly dropped.) +- **CopilotAgent** behavior — unchanged. If a shared module is factored out, Copilot keeps identical behavior behind its own config. +- **Shipping file content bytes** through the protocol — like CopilotAgent, we ship the editable `uri` only; the client reads content on open. +- **Client-pushed customization sync** (`setClientCustomizations` / `IAgentPluginManager`) — unchanged; only the *discovered* tier changes. + +## Prerequisites + +- Phase 11 (customizations surface, projector, `ISdkResolvedCustomizations` snapshot) ✅. +- `pluginParsers.ts` exports `parseAgentFile` / `parseSkillFile` / `readSkills` / `readMarkdownComponents` / `parseMcpServerDefinitionMap` (verified present). +- No external dependencies. + +## Approach + +Build a Claude-specific disk resolver that reuses the shared frontmatter/MCP parsers (`pluginParsers.ts`) — the genuinely reusable layer — and encodes Claude's own roots (`~/.claude/**`, `.claude/commands`, `settings.json`/`.mcp.json` MCP), rather than forcing the Copilot-convention-coded `SessionCustomizationDiscovery` onto Claude. `getSessionCustomizations` returns `client-pushed ∪ disk-resolved`, where the disk-resolved set is unfiltered while provisional and intersected with the live SDK snapshot (by name, per type) once a `ClaudeSdkPipeline` exists. The synthetic-stub bundler is retired (real URIs replace stub URIs). + +## Steps + +1. ✓ **Claude disk-scan resolver.** New module under `node/claude/customizations/` that walks Claude's roots (scoped by `cwd` + user home) and parses each file via `pluginParsers.ts` into discovered entries carrying the real `file:` URI + parsed name/description + source-kind (agent/skill/command). Reuse `readSkills` / `readMarkdownComponents` / `parseAgentFile` / `parseSkillFile`; commands use **flat `.md`** discovery (not `/SKILL.md`). + - Files: create `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` + - Depends on: none + - Done when: a unit test points the resolver at a temp tree (`~/.claude/agents/a.md`, `.claude/skills/s/SKILL.md`, `.claude/commands/c.md`) and gets entries with the correct real URIs + names. + +2. ✓ **Claude MCP-from-JSON scanner.** Read `~/.claude/settings.json`, `/.claude/settings.json`, `/.mcp.json`; call `parseMcpServerDefinitionMap` ([pluginParsers.ts](../../../agentPlugins/common/pluginParsers.ts)); emit `McpServerCustomization[]` with the real source-file URI (+ `range` for the entry span where feasible). Pre-materialize entries carry config but no live connection status. + - Files: `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` (or a sibling `claudeMcpDiscovery.ts`) + - Depends on: none (parallel with step 1) + - Done when: a unit test parses a `settings.json` with `mcpServers` and yields entries with real URIs. + +3. ✓ **Discovered → protocol mapper.** Convert discovered entries into `Customization` objects: `DirectoryCustomization` containers (agents/skills/commands) with child `uri` = real source file, plus top-level `McpServerCustomization`. Mirror CopilotAgent's `toDiscoveredChildCustomization` shape ([copilotAgent.ts:2258-2310](../copilot/copilotAgent.ts#L2258)). + - Files: `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` (mapper section) + - Depends on: steps 1, 2 + - Done when: mapper output validates against the `Customization` union ([state.ts](../../common/state/protocol/channels-session/state.ts)); child URIs are real files. + +4. ✓ **Rework `projectSessionCustomizations` / add `buildDiscoveredCustomizations`.** Pre-materialize (no SDK) → `synced ∪ discovered`. Post-materialize (SDK snapshot) → `synced ∪ (discovered ∩ SDK-known by (name,type)) ∪ sdkOnlyFallback`, where `sdkOnlyFallback` is the SDK-known names with no matching disk entry, rendered **non-editable** via a `claude-internal:` URI (Decision D2). + - Files: [claudeSessionCustomizationsProjector.ts](./customizations/claudeSessionCustomizationsProjector.ts) + - Depends on: step 3 + - Done when: unit tests cover both modes incl. the per-type name match (commands+skills filtered by the SDK command-name set; agents by agent set; MCP by server set) and the non-editable SDK-only fallback entries. + +5. ✓ **Wire resolver into `ClaudeAgentSession.getSessionCustomizations`.** Replaced `_sdkBundler`: scan disk (project + user) via `scanClaudeDiskCustomizations` + `scanClaudeMcpServers`, build via `buildDiscoveredCustomizations` (SDK snapshot used as filter post-materialize), project via `projectSessionCustomizations`. Session now requires `@IFileService` + `@INativeEnvironmentService`. + - Files: [claudeAgentSession.ts](./claudeAgentSession.ts), [claudeAgent.ts](./claudeAgent.ts) (construct/own the resolver per session) + - Depends on: steps 3, 4 + - Done when: `getSessionCustomizations` on a provisional session returns real-URI disk items; on a materialized session it filters by the SDK set. + +6. ✓ **Watcher-backed refresh.** `ClaudeCustomizationWatcher` (in the discovery module) watches `/.claude` + `/.claude` (recursive) and `` (non-recursive, trigger narrowed to `.mcp.json`) via `fileService.watch` + `onDidFilesChange`, debounced (`RunOnceScheduler`, 300ms; injectable for tests). Constructed in the session ctor (covers the provisional window) and registered as a session disposable; its `onDidChange` fires `_onDidCustomizationsChange`. + +7. ✓ **Retire the stub bundler + fix `_resolveAgentName`.** The non-editable SDK-only fallback now lives in `buildDiscoveredCustomizations` (Step 4), so `ClaudeSdkCustomizationBundler` (the synthetic-tree writer) was fully **dead** — deleted along with its test; `CLAUDE_SDK_DEFAULT_AGENT_NAME` relocated into the discovery module (its only consumer). `_resolveAgentName` is now async: for a real `file:` agent it parses the frontmatter `name` via `parseAgentFile` (the SDK keys agents by frontmatter name, not filename; falls back to the basename on read failure); for a `claude-internal:` URI it decodes the name from the path. Both materialize + resume-rebuild call sites updated to await it. + +8. **Tests.** New resolver/mapper/projector tests; update `FakeQuery` (which throws for `supportedAgents()`/`mcpServerStatus()` — [claudeAgent.test.ts:~459](../../test/node/claudeAgent.test.ts#L459)) to model SDK snapshots; update/replace the existing bundler tests; add the pre- vs post-materialize projection tests. + - Files: create `test/node/claudeSessionCustomizationDiscovery.test.ts`; modify [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts); delete/replace `test/node/customizations/claudeSdkCustomizationBundler.test.ts` + - Depends on: steps 1-7 + - Done when: `node` agentHost suite green; new resolver + projector tests pass. + +## Files to Modify or Create + +> Reflects the **final shipped** file set. The numbered Steps above are the +> original plan; the structure consolidated during implementation (the +> projector was inlined, the disk scan split into a `scan/` subfolder, and a +> built-ins module was added). See Implementation Notes for the full deviation +> log. + +| Path | Change | Notes | +|------|--------|-------| +| `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` | create | Discovered→protocol mapper (`mapDiscoveredCustomizations`), pre/post-materialize logic (`buildDiscoveredCustomizations`), `resolveClaudeAgentName`, `ClaudeCustomizationWatcher`, `CLAUDE_SDK_DEFAULT_AGENT_NAME`, `nonEditableUri`/`scopeOf` | +| `node/claude/customizations/scan/claudeAgentSkillScan.ts` | create | Agents + skills (+ commands folded into skills) disk scan, reusing `pluginParsers.ts` | +| `node/claude/customizations/scan/claudeMcpScan.ts` | create | MCP-from-JSON scan (`settings.json` `mcpServers` block + flat `.mcp.json`) | +| `node/claude/customizations/scan/claudeRuleScan.ts` | create | Rules scan (CLAUDE.md + `.claude/rules/**`); exports `claudeMemoryFiles` | +| `node/claude/customizations/claudeBuiltinCommands.ts` | create | Read-only "Built-in" surfacing: `CLAUDE_BUILTIN_COMMANDS` (13), `CLAUDE_BUILTIN_AGENTS` (5), `buildClaudeBuiltinSkillsContainer` / `buildSdkBuiltinSkillsContainer`; `AGENT_BUILTIN_SCHEME` inlined here | +| [claudeAgentSession.ts](./claudeAgentSession.ts) | modify | Two-mode `getSessionCustomizations` (scan disk + optional SDK filter + projection inlined — no separate projector); requires `@IFileService` + `@INativeEnvironmentService`; wires the watcher; calls `resolveClaudeAgentName` | +| `test/node/customizations/claudeSessionCustomizationDiscovery.test.ts` | create | Resolver + mapper + SDK-filter tests | +| `test/node/customizations/claudeBuiltinCommands.test.ts` | create | Built-in container construction (curated + SDK-derived) | +| [test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | modify | Disk-scan/built-in assertions; `FakeQuery` snapshot modeling | +| ~~`customizations/claudeSdkCustomizationBundler.ts`~~ | delete | Synthetic-stub bundler **deleted** (not reduced); fallback is now declarative inline in `buildDiscoveredCustomizations` | +| ~~`test/node/customizations/claudeSdkCustomizationBundler.test.ts`~~ | delete | Deleted with the bundler | + +## Decisions + +- **D1 — Claude-specific resolver reusing `pluginParsers.ts` (grill-confirmed).** Reuse `pluginParsers.ts` (the genuinely provider-neutral layer); write Claude's own root-walking + watcher under `node/claude/customizations/`. Do **not** reuse or factor `node/copilot/sessionCustomizationDiscovery.ts` — it's Copilot-convention-coded and couples the providers. Least coupling, no layering risk. +- **D2 — SDK-known-but-not-on-disk → show as a non-editable entry (grill-confirmed).** Items the live SDK reports but the disk scan can't locate (built-ins, plugin-provided, unscanned dirs) are surfaced with name+description and a synthetic non-`file:` URI so the workbench renders them **read-only** — keeping the active capability list complete while only locatable items are editable. As shipped, the stub bundler was **deleted entirely** (not merely reduced): the fallback is generated declaratively inline in `buildDiscoveredCustomizations` via `claude-internal:` / `agent-builtin:` URIs, so no stub files are written to disk. (Council majority leaned *drop*; user chose *show* to keep the list honest.) +- **D2b — Read-only built-in agents + skills (post-plan addition).** Beyond the SDK-only fallback, a **curated** set of built-in slash commands (`CLAUDE_BUILTIN_COMMANDS`, 13) and built-in agents (`CLAUDE_BUILTIN_AGENTS`, 5) is surfaced read-only **pre-materialize** for discoverability (the SDK can't report its command/agent set before a live session exists). Post-materialize, the live `supportedCommands()` / `supportedAgents()` set supersedes the curated list. Lives in `claudeBuiltinCommands.ts` on the `agent-builtin:` scheme; each description is literally prefixed `(Built-In) `. These entries are display-only — `contrib/chat` is deliberately untouched, so clicking a built-in does not render content (accepted limitation; a read-only editor view is a future request). +- **D3 — Commands ARE skills (no separate category).** Per the customizations spec (`src/vs/sessions/copilot-customizations-spec.md` §3), slash commands in `.claude/commands/*.md` are *a variant of skills, treated internally as skills*, with skills winning same-name conflicts. The resolver therefore scans `.claude/commands/` but maps the results to `ClaudeCustomizationKind.Skill` (no `Command` kind, no separate commands container) and collects skills before commands so skills win. The SDK's `supportedCommands()` set (which represents loaded skills) filters disk skills. Commands are scanned as **flat `.md`**. +- **D4 — Watcher-backed refresh.** Reuse the correlated-watcher + debounce pattern; scan + watch, fire `onDidCustomizationsChange` on change. Unanimous council. +- **D5 — Lifecycle untouched.** `createSession` stays provisional/cold; the disk scan needs no warm SDK. No changes to materialize / sequencer / `onDidMaterializeSession` / `provisional`. +- **D6 — MCP status asymmetry.** Pre-materialize MCP entries carry declared config from `settings.json` but **no live connection status**; post-materialize, status is enriched/filtered from `mcpServerStatus()` by name. + +## Risks + +- **`_resolveAgentName` depends on the bundler's synthetic URI.** Switching to real URIs can break agent selection if the basename→SDK-agent-name extraction assumed the stub path. *Mitigation:* step 7 audits and rebinds it to the discovered entry; test agent-select end-to-end. ([claudeAgentSession.ts:~577](./claudeAgentSession.ts#L577)) +- **Name-match false positives in the post-materialize filter.** SDK names matched against disk entries by name alone could collide across kinds or aliases (`SlashCommand.aliases`). *Mitigation:* match by `(name, type)` case-insensitively; keep source-kind on each entry (D3). +- **Layering (`node/claude` → shared/copilot).** If any factoring reaches into `node/copilot`, `valid-layers-check` may complain. *Mitigation:* keep the resolver Claude-local (D1); run `npm run valid-layers-check`. +- **`FakeQuery` throws for `supportedAgents`/`mcpServerStatus`.** Live-filter tests need modeled snapshots. *Mitigation:* step 8 updates the fake. ([claudeAgent.test.ts:~459](../../test/node/claudeAgent.test.ts#L459)) +- **Watcher leaks / event storms.** Unbounded re-scan on rapid disk changes. *Mitigation:* debounce + cancelable scan (the Copilot `SessionDiscoveredEntry` pattern); register watcher as a session disposable. +- **`settings.json` MCP format drift vs `.mcp.json`.** `parseMcpServerDefinitionMap` normalizes `{mcpServers:…}` and flat maps; confirm Claude's `settings.json` MCP block matches. *Mitigation:* defensive parse + fall through on shape mismatch. + +## Verification + +### Unit / Integration +- Unit (runTests tool, `node` agentHost suite): resolver yields real URIs for agents/skills/commands/MCP from a temp tree; `projectSessionCustomizations` pre-mode returns the full set, post-mode intersects by `(name,type)`; MCP parsed from `settings.json`; commands scanned as flat `.md`. +- Integration: `getSessionCustomizations` on a provisional session → disk items with real URIs, no SDK filter; after materialize → on-disk items the live session didn't load are hidden; client-pushed tier unchanged. +- Type/layer/hygiene: `npm run typecheck-client`, `npm run valid-layers-check`, gulp `hygiene` clean. + +### E2E +Workspace skills (project scope): `launch` (drive Code OSS Agents window), `code-oss-logs` (read `agenthost.log`). +- **Scenario:** + 1. Put a real customization on disk (e.g. `~/.claude/agents/explore.md` with frontmatter + a body). + 2. `launch` the Agents window, authenticate, pick **Claude**, create a session, and **without sending a message** open the customization list. + 3. Confirm `explore` appears and **opening it opens the real `~/.claude/agents/explore.md`** (with its body) — not an empty stub. + 4. Send a message (materialize). Add a deliberately-broken `~/.claude/agents/broken.md` the SDK won't load; confirm it is **hidden** post-materialize while `explore` remains. + 5. Edit `explore.md` on disk; confirm the list refreshes (watcher). + 6. `code-oss-logs`: confirm no `[Claude]` customization warnings and the scan ran without the stub bundler. + +### Manual +- Confirm an MCP server declared in `~/.claude/settings.json` appears (config) pre-materialize and shows live status post-materialize. + +## Open Questions + +_None — both resolved during grill (2026-06-17): D1 (Claude-specific resolver reusing `pluginParsers.ts`) and D2 (SDK-known-but-not-on-disk shown as non-editable)._ + +## References + +- Roadmap: [roadmap.md](./roadmap.md) (Phase 16, redesigned) +- Phase 11 customizations: [phase11-plan.md](./phase11-plan.md) +- CopilotAgent disk-scan reference: [sessionCustomizationDiscovery.ts](../copilot/sessionCustomizationDiscovery.ts), [copilotAgent.ts](../copilot/copilotAgent.ts) (`toDiscoveredChildCustomization` ~L2258) +- Shared parsers: [pluginParsers.ts](../../../agentPlugins/common/pluginParsers.ts) +- Protocol type: [state.ts](../../common/state/protocol/channels-session/state.ts) (`Customization`, `DirectoryCustomization`, `McpServerCustomization`) +- E2E skills used: `launch`, `code-oss-logs` + +## Implementation Notes + +_Status: in progress — Steps 1–7 of 8 complete (resolver + SDK filter + wiring + watcher + bundler retired + agent-name fix)._ +### Files actually changed +- **created** `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` — the resolver module: + - Step 1: `scanClaudeDiskCustomizations(workingDirectory, userHome, fileService)` + `ClaudeCustomizationKind` + `IClaudeDiscoveredCustomization`. Reuses `readAgentComponents` (agents + commands) / `readSkills` (skills). + - Step 2: `scanClaudeMcpServers(workingDirectory, userHome, fileService)` — reads `settings.json` (explicit `mcpServers` block only) + `.mcp.json` (flat map allowed) via `readJsonFile`, builds `McpServerCustomization[]` with real URIs + `state: Starting`. + - Step 3: `mapDiscoveredCustomizations(discovered, mcpServers, userHome)` — wraps agents/skills in `DirectoryCustomization` containers (skills + commands together), real-file children, top-level MCP. +- **created** `test/node/claudeSessionCustomizationDiscovery.test.ts` — 3 tests. + +### Tests written +- *scans Claude agent / skill / command roots with real URIs and parsed names* — Step 1. **Green.** +- *scans MCP servers from settings.json with real URIs, ignoring unrelated settings keys* — Step 2 (regression-guards the settings.json non-`mcpServers` keys). **Green.** +- *maps discovered entries into Directory containers with real child URIs + top-level MCP* — Step 3. **Green.** +- *project scope shadows user scope on name clash* — precedence regression (council review). **Green.** +- *scans a flat .mcp.json server map* — council review gap. **Green.** +- *settings.json without an mcpServers block yields no servers* — council review gap. **Green.** +- *ignores array-valued MCP entries* — council review gap. **Green.** +- runTests: 7 passed. + +### Council review (Steps 1–3, 2 models: GPT-5.5 + Opus 4.6) +Must-fixes found and **resolved**: +- 🔴 `readSkills` precedence was nondeterministic (parallel `Promise.all` over dirs with a shared `seen` set → user could shadow project). **Fixed:** scan each scope independently, merge project-first via a per-kind name map. Covered by the precedence test. +- 🔴 `readSkills(userHome, …)` could match a spurious `/SKILL.md` via the no-skills fallback. **Fixed:** pass the real `/.claude/skills` dir as `pluginRoot`. +- 🔴 commands were folded into a `skills` container whose URI misrepresented their source. **Fixed:** commands now get their own `DirectoryCustomization` (`contents: Skill`, `uri: .claude/commands`). +- 🟡 array-valued MCP configs passed the object guard. **Fixed:** added `Array.isArray(config)` exclusion. Covered by test. +- 🟡 test gaps (precedence, flat `.mcp.json`, settings-without-`mcpServers`, array config) — **all added.** + +### Deviations from the plan +- Step 1 resolver is a free function (not a class) — matches `pluginParsers.ts` style; the watcher (Step 6) will wrap it. +- Agents + commands use `readAgentComponents` (= `readMarkdownComponents` + frontmatter enrichment), not the bare `readMarkdownComponents` the plan named — we want descriptions. +- **Step 2 does NOT use `parseMcpServerDefinitionMap`** (the plan named it). That function requires an `IPluginFormatConfig` + does env-var interpolation we don't need. `scanClaudeMcpServers` reads the JSON directly with a deliberate guard so `settings.json`'s unrelated top-level keys are not mistaken for servers (only the explicit `mcpServers` block counts), but builds each entry with the now-**exported** shared `makeMcpServerCustomization` (so Claude MCP entries carry `DEFAULT_MCP_APP` + a consistent id, fixing an earlier gap where the hand-built entries omitted `mcpApp`). + +### pluginParsers reuse (post-review dedup) +`IClaudeDiscoveredCustomization` was retired entirely: the scan now returns the shared `IParsedAgent | IParsedSkill` (resource + built child customization), the agent/skill `kind` discriminator becomes `customization.type`, and the mapper/fallbacks push `entry.customization` instead of hand-building `{ type, id, uri, name, description }` literals. Three `pluginParsers` builders were promoted from private to **exported** (`toParsedAgent`, `toParsedSkill`, `makeMcpServerCustomization`) with new unit tests in `pluginParsers.test.ts`. The MCP reuse also closed the missing-`mcpApp` inconsistency. +- **Step 3 emits one container per kind** (an Agents `DirectoryCustomization` + a Skills one + a Commands one) rather than one-per-source-directory like CopilotAgent. The protocol `Customization` union has no bare agent/skill member, so wrapping is required; per-kind containers keep each container `uri` honest (`.claude/{agents,skills,commands}`) and the **child URIs remain the real files** (the load-bearing requirement). Container `uri` = user-home `.claude/` (`writable: true`). + +### Steps 4–5 (SDK filter + session wiring) +- **Step 4** lives in `claudeSessionCustomizationDiscovery.ts` as `buildDiscoveredCustomizations(discovered, mcpServers, userHome, sdk?)` rather than inside the projector: `sdk` undefined → full mapped set; `sdk` present → disk filtered by `(name, type)` against the SDK agent/command sets, SDK-known-not-on-disk added as **non-editable** entries via `nonEditableUri('agent'|'skill'|'mcp', name)` (`claude-internal:` scheme), `CLAUDE_SDK_DEFAULT_AGENT_NAME` ('general-purpose') hidden, MCP state enriched via `deriveMcpState(status)`. `projectSessionCustomizations` was widened to take `discovered: readonly Customization[]` (was a single optional) and just spreads them. +- **Step 5**: `ClaudeAgentSession` constructor gained required `@IFileService` + `@INativeEnvironmentService` (VS Code DI has **no `optional` decorator** — confirmed; an `@optional` attempt was reverted). `getSessionCustomizations` now scans disk (project + user) in parallel, pulls the SDK snapshot when a pipeline exists (swallowing failures → unfiltered fallback), builds, and projects. `_sdkBundler` field + its construction removed. +- **Test harness**: added a `claudeFileEnvServices(disposables)` helper (in-memory `FileService` on `Schemas.file` + mock `INativeEnvironmentService` with `userHome = /mock-home`) spread into the `createTestContext` and `buildCtxWith` `ServiceCollection`s. Nothing is seeded under the mock home, so the disk scan is deterministically empty → existing projection tests still assert `length === 1` (client-pushed only). + +### Tests (Steps 4–5) +- `claudeSessionCustomizationDiscovery.test.ts`: now **8 green** (added post-materialize SDK-filter test). +- `claudeAgent.test.ts`: **124 green** after wiring the two services into the harness (was 122 + 2 failing on `userHome` of undefined). + +### Step 6 (watcher) +- `ClaudeCustomizationWatcher extends Disposable` added to the discovery module: recursive watch on both `.claude` roots + a non-recursive `` watch whose trigger is narrowed to `/.mcp.json` (so unrelated workspace-root edits don't re-scan). `affects()` matches descendants, so any change under a `.claude` root triggers; debounce via `RunOnceScheduler` (300ms default, last constructor arg overridable → tests use 5ms). Failed `fileService.watch` calls are warn-logged, not fatal. +- Wired in the session ctor (`new ClaudeCustomizationWatcher(this.workingDirectory, userHome, this._fileService, this._logService)`), both the watcher and its `onDidChange` subscription `_register`ed. +- **DI note:** VS Code's `InstantiationService` injects `undefined` for an unregistered service rather than throwing — so adding the watcher (which reads `userHome` at construction) surfaced 4 more session-constructing test `ServiceCollection`s that lacked the env service (previously only the 2 `getSessionCustomizations` tests failed). Wired `claudeFileEnvServices` into those 4 blocks (2007 / 3122 / 3173 / 3530). +- Test: *watcher fires once (debounced) for changes under watched roots and ignores unrelated edits* — **green** (9 total in the discovery suite). `claudeAgent.test.ts` back to **124 green**. + +### Step 7 (retire bundler + agent-name resolution) +- **Deleted** `claudeSdkCustomizationBundler.ts` + `customizations/claudeSdkCustomizationBundler.test.ts` (the synthetic-tree writer is fully superseded by the D2 fallback in `buildDiscoveredCustomizations`). `CLAUDE_SDK_DEFAULT_AGENT_NAME` moved into `claudeSessionCustomizationDiscovery.ts`. Net: the implementation **retires** rather than "reduces" the bundler — cleaner, since the fallback is generated declaratively (no stub files written to disk). +- `_resolveAgentName` rewritten async: `claude-internal:` → decode name from path; `file:` → `parseAgentFile` frontmatter `name` (basename fallback on read error). Both buildOptions call sites (materialize + resume rebuild) now `await` it. +- Test: *materialize resolves the SDK agent name from the file frontmatter, not the filename* — seeds `~/.claude/agents/foo.md` with `name: my-real-agent`, asserts `capturedStartupOptions[0].agent === 'my-real-agent'`. `createTestContext` now exposes its in-memory `fileService` so tests can seed `.claude/**`. **125 + 9 = 134 green.** + +### Council review (Steps 4–7, 3 models: GPT-5.5 + Opus 4.6 + GPT-5.3-Codex) +Consensus + single-but-evidenced findings, all **resolved**: +- 🔴 **(all 3) stale projector test.** `customizations/claudeSessionCustomizationsProjector.test.ts` still called the old single-optional signature (`undefined` / a bare `Customization`) after Step 4 widened it to `readonly Customization[]` — compile error + would crash on `push(...undefined)`. **Fixed:** updated all four call sites to `[]` / `[entry]`. +- 🟡 **(Codex) default agent not hidden when on disk.** `CLAUDE_SDK_DEFAULT_AGENT_NAME` was only suppressed in the SDK-only fallback loop, so a real `~/.claude/agents/general-purpose.md` could still surface post-materialize. **Fixed:** skip it in the disk-match agent loop too. +- 🟢 renamed `seenSkills` → `seenCommandNames` + added a D3 comment (the set tracks the SDK's single command-name space matched by both disk skills and commands). +- 🟡 *(SDK-only commands route to the skills container)* — acknowledged as inherent: `supportedCommands()` doesn't distinguish skill vs command, and these entries are non-editable anyway, so the container is cosmetic. Left as-is per D3. +- 🟡 *(no scan cache; rescans per fetch)* — accepted; the watcher gates refetch frequency and the sets are small. Not worth a cache layer now. + +### Type-safety catch (post-review) +`runTests` (esbuild) is transpile-only and the LSP/`Core - Typecheck` watch showed **stale** diagnostics, so an authoritative `npm run typecheck-client` was run — it surfaced **3 real type errors** that the green test run had masked: (1) `agent: { uri: agentFile }` needed `.toString()` (config wants a string URI); (2) `buildCtxWith` didn't return the new required `ITestContext.fileService` (inlined its file service like `createTestContext`); (3) the discovery test's `mcp` literal needed an explicit `McpServerCustomization[]` annotation. All fixed; `typecheck-client` now clean. **138 green** across the three suites. + +### Step 8 status +- Bundler tests: **deleted** (Step 7). Pre/post-materialize projection: covered by the discovery suite's SDK-filter test + the projector suite. Agent-name resolution: covered by the new materialize test. +- `FakeQuery` SDK-snapshot *success-path* modeling (live post-materialize filter end-to-end) is **deferred to the E2E** (launch skill) — the filter logic itself is exhaustively unit-tested via `buildDiscoveredCustomizations`, and the existing *swallows SDK snapshot failure* test covers the failure path. + +### E2E (launch skill — Agents window, real product) ✓ +Launched the Agents window from sources (`launch.sh --agents`) signed-in, drove it with `@playwright/cli`, with two real agents already on disk at `~/.claude/agents/{dead-code-detector,unit-test-writer}.md` (user scope). +- ✅ **Disk scan surfaces real files.** The composer's agent picker ("Pick Mode, Agent") lists both real agents; the header shows **"Agents 2" / "Skills 13"** counts from the scan (user + project scope). +- ✅ **Real names + descriptions parsed.** "Configure Custom Agents…" opens the **"Agent Customizations for Claude [Agent Host]"** dialog listing both agents with their **full real frontmatter descriptions** (the old stub bundler had none) — confirming `scanClaudeDiskCustomizations` parsed the actual files. +- ✅ **Editable real file opens (not a stub).** Clicking the `dead-code-detector` entry logged `customizationsDebug.log … [user] /Users/tyleonha/.claude/agents/dead-code-detector.md` and `renderer.log … [text file model] resolve() - enter file:///Users/tyleonha/.claude/agents/dead-code-detector.md` — i.e. the workbench opened the **real** `~/.claude/agents/dead-code-detector.md` for editing, the core requirement. (Old behaviour opened a synthetic `claude-discovered//…` stub.) +- ✅ **No regressions in logs.** `agenthost.log` shows no customization/scan/`_resolveAgentName` errors and no `claude-discovered` stub-bundler activity (only unrelated launcher noise: `vscode-userdata` provider + sticky-bit). +- Scenarios 2 (post-materialize filter) and 4 (live watcher) were left to the exhaustive unit coverage (`buildDiscoveredCustomizations` filter tests + the debounced watcher test) rather than a full SDK materialize run. +- Launch gotcha (recorded in repo memory): macOS `$TMPDIR` is too long for the IPC `.sock` (`listen EINVAL`) — relaunch with a short `TMPDIR=/tmp/...`. + +### Post-review correction — commands are skills (D3 revised) +The initial implementation modelled a separate "commands" category (`Command` kind + a `.claude/commands` container). The customizations spec (§3) is explicit: commands are *a variant of skills, treated internally as skills*. **Removed** the `Command` kind and the commands container; the resolver still scans `.claude/commands/*.md` but folds the results into skills (collected after skills so skills win same-name conflicts). Tests updated (commands-folded-into-skills + a skill-wins-over-same-named-command priority test); **139 green**. + +### E2E — skills (follow-up to the agents-only first pass) ✓ +Re-launched against the vscode repo (whose `.claude/skills` → `.agents/skills` symlink exposes the `launch` skill at workspace scope). The Claude session's **"Skills 13"** = 1 workspace (`launch`) + 12 user-scope (`~/.claude/skills/*`), each with its real parsed description. Clicking `launch` opened the **real workspace file** `renderer.log: [text file model] resolve() - enter file:///Users/…/vscode/.claude/skills/launch/SKILL.md` — confirming workspace-scope skills resolve through the symlinked layout and open for editing. + +### Per-scope containers (Workspace vs User) +Follow-up: `mapDiscoveredCustomizations` originally rooted every container at `userHome/.claude/`, so the agents-window editor grouped all entries under "User". Fixed by mirroring CopilotAgent's [toDiscoveredDirectoryCustomizations](../copilot/copilotAgent.ts#L2236): emit **one `DirectoryCustomization` per (scope, kind)** whose `uri` is the real `/.claude/` directory (`scopeOf` attributes each child via `isEqualOrParent(uri, workingDirectory)`; `claude-internal:` SDK-only entries fall to User). The workbench derives the "Workspace" / "User" label from the container `uri`. `buildDiscoveredCustomizations` / `getSessionCustomizations` now thread `workingDirectory`. E2E re-verified: the Skills section shows **"Workspace, 1 items"** (`launch`) separate from **"User, 12 items"**. + +### Post-plan: read-only built-in agents + skills, consolidation, and structure +After the 8 planned steps, the surface grew a curated built-in tier and the +files were consolidated. Net shipped structure differs from the Steps/old +table above (now corrected): +- **Built-ins (`claudeBuiltinCommands.ts`).** New module surfacing + `CLAUDE_BUILTIN_COMMANDS` (13 slash-command skills) and + `CLAUDE_BUILTIN_AGENTS` (5 agents) as **read-only** entries on the + `agent-builtin:` scheme. `buildClaudeBuiltinSkillsContainer` seeds the + curated set pre-materialize; `buildSdkBuiltinSkillsContainer` takes the live + SDK command set post-materialize (filtering out anything discovered on + disk). Each description literally starts with `(Built-In) ` — a localized + string prefix (no concatenation), chosen over a wrapper helper for + simplicity. `AGENT_BUILTIN_SCHEME = 'agent-builtin'` is inlined here (a + short-lived `common/agentBuiltinCustomizations.ts` was created then deleted). +- **Scan split.** Disk scanning moved into `customizations/scan/` + (`claudeAgentSkillScan.ts`, `claudeMcpScan.ts`, `claudeRuleScan.ts`); the + discovery module imports from there. +- **Projector inlined.** There is **no** `claudeSessionCustomizationsProjector.ts` + and **no** `projectSessionCustomizations` function — the projection (merge + client-pushed + discovered, apply the per-id enablement overlay to + client-pushed only) is inlined directly in + `ClaudeAgentSession.getSessionCustomizations`. +- **Agent-name resolution.** `resolveClaudeAgentName` lives in the discovery + module (not `claudeAgentSession.ts`); both materialize + resume-rebuild call + sites await it. +- **`contrib/chat` untouched.** Built-ins are display-only; clicking one does + not render content. Accepted limitation — a read-only editor view is a + future request. + +_Status: **implemented** — all 8 steps + the built-in tier complete; `typecheck-client` clean; ESLint clean; 3-model council review applied; E2E validated in the real Agents window._ diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index 74eb2474efecc..02bb80ea1776e 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -1093,6 +1093,9 @@ Shipped in PR #318113. Two-tier model: single "Discovered in Claude" Open Plugins-conformant on-disk tree under `IAgentPluginManager.basePath`, namespaced by working-directory hash and nonce-stable across repeated bundles of the same SDK snapshot. + **(Superseded by Phase 16: the synthetic-stub bundle was replaced by a + disk scan returning real editable `file:` URIs; `ClaudeSdkCustomizationBundler` + is deleted.)** **Per-session ownership.** All customization state lives on `ClaudeAgentSession`: @@ -1107,6 +1110,7 @@ Shipped in PR #318113. Two-tier model: demand from `getSessionCustomizations`. Repeated calls with the same SDK snapshot skip the rewrite. The tree is intentionally a cross-session warm cache (not deleted on session dispose). + **(Superseded by Phase 16 — this bundler is deleted; see Phase 16.)** Full step-by-step plan: [phase11-plan.md](./phase11-plan.md). @@ -1276,22 +1280,33 @@ fork end-to-end ships in Phase 6.5. Exit criteria: ready to enable for external preview. -### Phase 15 — SDK distribution via `product.json` + main.vscode-cdn.net - -**Status as of 2026-06-12:** Runtime shape is per-SDK `urlTemplate` + -runtime `{sdkTarget}` substitution (replaced the per-platform -`{url, sha256}` pair so macOS Universal bundles can share one -`product.json`; see -[`phase15-per-platform-plan.md`](./phase15-per-platform-plan.md) and the -follow-up `urlTemplate` PR). The Claude and Codex SDK distributions are -declared in `product.json` and downloaded on demand by +### Phase 15 — SDK distribution via `product.json` + main.vscode-cdn.net ✅ **DONE** + +> **Implementation contract / retrospective: +> [phase15-plan.md](./phase15-plan.md).** That file documents what +> actually shipped — runtime downloader, the `build/agent-sdk/` tarball +> pipeline, the per-platform `produce.ts` step, and the gulpfile +> `product.json` stamping. The summary below stays high-level for roadmap +> continuity. + +**Status:** both the runtime and the build pipeline have landed. Runtime +shape is per-SDK `urlTemplate` + runtime `{sdkTarget}` substitution +(replaced the per-platform `{url, sha256}` pair so macOS Universal bundles +can share one `product.json`). The Claude and Codex SDK distributions are +declared in `product.json` (built by the per-platform +[`produce.ts`](../../../../../../build/agent-sdk/produce.ts) step and +stamped into `product.json` by `packageTask` in +[`gulpfile.vscode.ts`](../../../../../../build/gulpfile.vscode.ts)) and +downloaded on demand by [`agentSdkDownloader.ts`](../agentSdkDownloader.ts) into `/agent-host/sdk-cache////`. The hand-supplied paths (`chat.agentHost.claudeAgent.path` → `AgentHostClaudeSdkRootEnvVar`, see [`claudeAgentSdkService.ts:148`](./claudeAgentSdkService.ts#L148), and the Codex equivalent) survive as a **dev override** only — set them to bypass -the download. +the download. SDK versions are pinned in +[`build/agent-sdk/agents//package.json`](../../../../../../build/agent-sdk/agents/claude/package.json) +(today: Claude `0.3.169`, Codex `0.135.0`). **Shape:** @@ -1328,105 +1343,162 @@ the download. registered and never appears in the agent picker (matches the pre-CDN UX). -**Exit criteria (met for runtime; build is a follow-up PR):** +**Exit criteria (met):** - Fresh insiders install can use Claude/Codex without manually installing the SDK or setting any path. -- SDK version bumps are now build-pipeline changes that re-pin - `product.agentSdks[pkg]` per platform during packaging. +- SDK version bumps are now build-pipeline changes that re-pin the SDK + version in `build/agent-sdk/agents//package.json` (+ lockfile); + the per-platform `produce.ts` step republishes the tarballs and + `packageTask` re-stamps `product.agentSdks[pkg]` during packaging. - Dev override keeps working for SDK development. -**Build pipeline** — see `build/agent-sdk/` for the tarball production -and CDN upload tooling, including the deterministic-tar setup that -makes `verify-determinism.ts` enforceable in CI. The inline integration -into each `packageTask(platform, arch, ...)` so that the gulpfile patches -`product.json` directly (no `AgentSDK` pipeline stage, no -`aggregate.ts`) ships in a follow-up PR per -[`phase15-per-platform-plan.md`](./phase15-per-platform-plan.md) §PR 2. - -### Phase 16 — Eager session materialization at create time - -**Status:** follow-up to Phase 11. Phase 11's -`getProjectedSessionCustomizations` already returns the SDK-resolved -customization tier when the pipeline is bound, but for provisional -sessions it returns only the client-pushed half. The full picture — -SDK-discovered skills (`~/.claude/skills/**`), agents (`.claude/agents/**`), -and `~/.claude/settings.json` MCP servers — only materializes after the -first `sendMessage`. Workbench UX wants the full list available -immediately on `createSession` so a draft session can show its true -capability surface before the user types. - -**Direction:** collapse the provisional/materialize split for the -non-fork `createSession` path. `createSession` synchronously -materializes (spawns the SDK subprocess, opens the proxy refcount, -runs the metadata write, fires `onDidMaterializeSession`) before -returning. - -**Why this is its own phase, not part of Phase 11.** Phase 11's -projector and SDK snapshot work stand on their own — they make -`getSessionCustomizations` correct *whenever* the pipeline is bound. -The eager-materialize change rewrites the M9 lifecycle contract, -touches the `_sessionSequencer`'s first-send branch, changes -disposable semantics for never-used sessions, and updates CONTEXT.md. -Coupling the two would inflate Phase 11's blast radius for no review -benefit; landing them serially keeps each change small. +**Build pipeline** — see [`build/agent-sdk/`](../../../../../../build/agent-sdk/README.md) +for the tarball production and CDN upload tooling, including the +deterministic-tar setup. The per-platform +[`agent-sdk-produce.yml`](../../../../../../build/azure-pipelines/common/agent-sdk-produce.yml) +template runs `produce.ts` before each `gulp vscode---min-ci` +step; `packageTask`'s `jsonEditor` callback then merges the results into +`product.json` via `readAgentSdkResults()` (no separate `AgentSDK` +pipeline stage, no `aggregate.ts`). Full retrospective in +[phase15-plan.md](./phase15-plan.md). + +### Phase 16 — Editable customization resolution via disk scan + +> **Redesigned 2026-06-17.** This phase originally proposed "eager +> session materialization at create time" — warming the SDK inside +> `createSession` so `getSessionCustomizations` could return the +> SDK-resolved tier before the first `sendMessage`. That premise is +> **retired.** Investigation (below) showed the SDK query APIs can't +> deliver what the customization UX actually needs, and that a disk +> scan — not a warm session — is the right resolution path. The +> warm-at-create machinery (and its JSONL-write-timing / cleanup-on- +> discard tail) is no longer part of this phase. + +**Status:** follow-up to Phase 11. Phase 11 bundles the SDK-discovered +customization tier (`Query.supportedCommands()` / `supportedAgents()` / +`mcpServerStatus()`) into a synthetic "Discovered in Claude" plugin +tree. The problem: those SDK APIs return **only names + descriptions — +no file paths and no content** (`SlashCommand` / `AgentInfo` / +`McpServerStatus` in `sdk.d.ts`). So today's bundler synthesizes **stub +files** (frontmatter with name + description, empty body) and ships +URIs pointing at those *stubs*. A user who opens a "Discovered in +Claude" item edits a generated stub, not their real +`~/.claude/agents/foo.md` — and there is no real content anywhere in +the projection. + +**Driver:** the customization surface must give the user the **real, +editable file path** of each customization (agents, skills, slash +commands, MCP servers). Content follows for free — like +`CopilotAgent`, we ship the real `uri` and the client reads content on +demand by opening it; we do **not** need to ship content bytes through +the protocol. + +**Direction:** resolve customizations by **scanning the file system** +(the `CopilotAgent` model), not by trusting the SDK query payloads. +The SDK list becomes a **post-materialize filter**, not the source of +the data. + +- **Pre-materialization (provisional / draft):** a disk scan resolves + the full set with real paths + parsed metadata. No warm SDK session + required, so `createSession` stays **cold and provisional** — no + subprocess, no proxy refcount, no JSONL, fully invisible in the + sidebar until the first `sendMessage`. **The provisional/materialize + split is preserved, not collapsed.** Show *everything* discovered on + disk (optimistic — no session yet to say what's actually active). +- **Post-materialization (live session):** intersect the disk-scan + superset with the SDK's "known" set (`supportedCommands` / + `supportedAgents` / `mcpServerStatus`, matched by name per type). + A customization discovered on disk that the live session did **not** + load (malformed, disabled, wrong `settingSource`, shadowed by + precedence) is **hidden** post-materialize. The warm session already + exists here, so the filter is free. + +**Why a disk scan (and why it's not really duplicating Claude).** +`CopilotAgent` already scans disk for customizations +(`sessionCustomizationDiscovery.ts` — which *already* walks +`.claude/agents` and `.claude/skills`) and parses frontmatter via the +shared `pluginParsers.ts` (`parseAgentFile` / `parseSkillFile` / +`readSkills` / `readMcpServers`). The Claude provider reuses that +infrastructure. The genuinely net-new surface is only: **user-home +`~/.claude/**`**, **`~/.claude/commands`** (slash commands), and +**Claude's `settings.json` / `.mcp.json` MCP format** — i.e. encoding +Claude's directory conventions, not reimplementing Claude. **Scope:** -- `ClaudeAgent.createSession` calls `_materializeProvisional(sessionId)` - synchronously before returning. Return value's `provisional` flag is - either dropped or redefined ("no on-disk transcript yet" rather than - "no SDK" — settle in the plan). -- `_sessionSequencer`'s "first call materializes" branch in - `sendMessage` is removed; every reachable session has a live pipeline. -- `disposeSession` for a never-sent session now tears down a live - subprocess (the existing teardown handles it but is no longer free — - audit cost). -- Fork path (Phase 6.5, when it lands) already materializes synchronously - on `forkSession` return — semantics align naturally. -- CONTEXT.md M9: revise the "Provisional sessions own no SDK - resources" invariant; relax the "two-phase contract is locked" - framing; update the lifecycle tables to reflect "creation is the - materialize trigger". Phase 16 owns the doc update. -- Tests that exercise the provisional → first-send materialize race - (Phase 10.5 regression coverage, Phase 11 mid-turn toggle race) - reworked against the new contract. -- `getSessionCustomizations` for a freshly-created session now returns - the full SDK-resolved + client-pushed projection without waiting on - a send. - -**Trade-offs accepted (documented for posterity):** - -- Drafting is no longer free — every `createSession` pays a subprocess - spawn, plugin sync, proxy refcount, and metadata write. -- A draft the user cancels without sending costs the same as a session - that runs a turn (minus the actual model call). -- The two-phase model (provisional → materialized) collapses into a - single phase for non-fork creation. Fork already materializes - eagerly; this aligns the two paths. - -**Open design points** (settle in the phase plan when scheduled): - -- Does `IAgentCreateSessionResult.provisional` get dropped, or - redefined to mean "no on-disk SDK transcript yet" (true until the - first message lands and the SDK persists)? Workbench callers may - rely on the flag for deferred-notification semantics. -- `_onDidMaterializeSession` fires from inside `createSession`. The - service-layer deferred `sessionAdded` dispatch (`agentService.ts:412`) - must still see the event between the create and the visibility - window — verify ordering. -- Failure modes: if materialization throws (proxy down, SDK install - broken), does `createSession` reject? Probably yes — the user has - no usable session anyway. Today's lazy path lets the failure surface - on first `sendMessage` instead; eager surfaces it earlier, which is - arguably better UX. -- E2E coverage: a workbench scenario that creates a session and - inspects `getSessionCustomizations` *without* sending a message, - verifies the full SDK-resolved list is present. - -Exit criteria: `getSessionCustomizations(freshlyCreatedSession)` -returns the full SDK + client-pushed projection synchronously after -`createSession` resolves; M9 doc updated; Phase 10.5 / 11 race tests -reworked and green. +- New Claude customization **disk-scan resolver** (mirrors + `CopilotAgent`'s discovery) covering, scoped by the session's `cwd` + + user home + `settingSources`: + - agents — `~/.claude/agents/**`, `/.claude/agents/**` + - skills — `~/.claude/skills/**`, `/.claude/skills/**` + - slash commands — `~/.claude/commands/**`, `/.claude/commands/**` + - MCP servers — `~/.claude/settings.json`, `/.claude/settings.json`, + `/.mcp.json` + - Reuse `pluginParsers.ts` + `sessionCustomizationDiscovery.ts` where + possible; add Claude-specific roots + the `settings.json` MCP parser. +- Each resolved item ships a **real `uri`** (editable file path) + parsed + name/description, replacing the synthetic-stub bundler for the + discovered tier. +- Rules (CLAUDE.md + `.claude/rules/**`) are scanned and surfaced too; + they have no SDK counterpart, so they bypass the post-materialize + filter (always kept). +- `getSessionCustomizations`: + - **provisional:** client-pushed ∪ full disk scan (no filter). + - **materialized:** client-pushed ∪ (disk scan ∩ SDK-known set). +- The synthetic-stub `claudeSdkCustomizationBundler` is **deleted**; the + SDK-only non-editable fallback is generated declaratively inline in + `buildDiscoveredCustomizations` (no stub files on disk). +- **Read-only built-in tier** (added during implementation): a curated set + of built-in slash commands (13) + built-in agents (5) is surfaced + read-only pre-materialize for discoverability (on the `agent-builtin:` + scheme), then superseded by the live SDK set post-materialize. Lives in + `claudeBuiltinCommands.ts`. Built-ins are display-only — `contrib/chat` + is untouched, so clicking one does not render content (accepted; a + read-only editor view is a future request). +- `createSession` is **unchanged** in lifecycle terms: stays + provisional, no warm SDK, no `onDidMaterializeSession` at create. + The provisional path simply gains a disk scan for its customization + projection. + +**What this phase explicitly does NOT do (retired from the old design):** + +- No eager / warm materialization inside `createSession`. +- No collapsing of the provisional/materialize split. +- No `IAgentCreateSessionResult.provisional` change, no + `_sessionSequencer` first-send-branch removal, no `onDidMaterialize` + ordering rework, no JSONL-write-timing investigation, no + cleanup-on-discard net. The lifecycle (M9) is untouched. + +**Open design points** (settle in the phase plan): + +- **SDK-knows-it-but-not-found-on-disk** (built-ins, plugin-provided, + or an unscanned dir): show it as a **non-editable name/description + entry** (so the active capability list stays complete) or **drop** + it? Leaning *show as non-editable* — retains the honest active set + while only the locatable items are editable. +- **Skills vs commands mapping** — the SDK surfaces skills via + `reloadSkills()` / `supportedCommands()` as `SlashCommand[]`, but the + disk layout separates `~/.claude/commands` (slash commands) from + `~/.claude/skills` (skills). The name-match filter needs a per-type + mapping so a skill isn't matched against a command. +- **File watching** — do we re-scan on disk change (live updates to the + customization list) or scan once per `getSessionCustomizations` call? + Prefer correlated watchers via `fileService.createWatcher` if live. +- **MCP completeness pre-materialize** — disk-scan reads declared MCP + servers from `settings.json`; their *connection status* is only known + post-materialize via `mcpServerStatus()`. Pre-materialize entries + carry config but no live status. + +Exit criteria: opening a discovered agent / skill / command from a +Claude session's customization list opens the **real on-disk file** +(editable), not a synthetic stub; a provisional (never-sent) session +lists the full disk-scanned set; a materialized session hides on-disk +customizations the live session did not load, and surfaces +SDK-known-but-not-on-disk items as **non-editable** entries; the +synthetic-stub bundler is **deleted** (the non-editable fallback and the +curated built-in agents/skills tier are generated declaratively inline, +no stub files on disk); `createSession` lifecycle (provisional, cold) is +unchanged. **Shipped.** --- diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 24dade78d60a6..85b4d512c402b 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -39,10 +39,16 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import type * as http from 'http'; import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { type AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { ResponsePartKind, ToolResultContentType, type ClientPluginCustomization } from '../../common/state/sessionState.js'; @@ -70,6 +76,23 @@ import { // #region Test fixtures +/** + * The {@link IFileService} + {@link INativeEnvironmentService} pair the + * Phase 16 customization disk scan / watcher needs at session construction + * time. Nothing is seeded under `userHome`, so the scan is deterministically + * empty — these only exist so `new ClaudeAgentSession` can read `userHome` + * and start its watcher without throwing. + */ +function claudeFileEnvServices(disposables: Pick): [typeof IFileService | typeof INativeEnvironmentService, IFileService | INativeEnvironmentService][] { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const env: Partial = { userHome: URI.file('/mock-home') }; + return [ + [IFileService, fileService], + [INativeEnvironmentService, env as INativeEnvironmentService], + ]; +} + const ANTHROPIC_MODEL: CCAModel = { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', @@ -595,6 +618,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], + ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -724,6 +748,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], + ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -782,6 +807,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], + ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 348add320d40d..63a299aaf01fa 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 { GetSessionMessagesOptions, McpSdkServerConfigWithInstance, Options, PermissionMode, Query, SDKMessage, SDKSessionInfo, SDKUserMessage, SdkMcpToolDefinition, SessionMessage, Settings, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentInfo, GetSessionMessagesOptions, McpSdkServerConfigWithInstance, McpServerStatus, Options, PermissionMode, Query, SDKMessage, SDKSessionInfo, SDKUserMessage, SdkMcpToolDefinition, SessionMessage, Settings, SlashCommand, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { CCAModel } from '@vscode/copilot-api'; @@ -23,6 +23,7 @@ import { makeThinkingDelta, } from './claudeMapSessionEventsTestUtils.js'; import { DeferredPromise } from '../../../../base/common/async.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import type { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; @@ -35,6 +36,10 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { FileService } from '../../../files/common/fileService.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ActionType } from '../../common/state/sessionActions.js'; @@ -185,6 +190,17 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { */ queryAdvance: ((index: number) => Promise) | undefined; + /** + * Phase 16 — programmable live SDK customization snapshot, read by + * {@link FakeQuery.supportedCommands} / `supportedAgents` / + * `mcpServerStatus`. `supportedCommands` defaults to `[]`; the other two + * stay unmodeled (throwing) until a test opts in, preserving the + * snapshot-failure coverage. + */ + supportedCommandsResult: SlashCommand[] = []; + supportedAgentsResult: AgentInfo[] | undefined = undefined; + mcpServerStatusResult: McpServerStatus[] | undefined = undefined; + /** All warm queries produced by {@link startup}. Last entry is the most recent. */ readonly warmQueries: FakeWarmQuery[] = []; @@ -461,11 +477,17 @@ class FakeQuery implements AsyncGenerator { initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } supportedCommands(): never { - return Promise.resolve([]) as never; + return Promise.resolve(this._sdk.supportedCommandsResult) as never; } supportedModels(): never { throw new Error('FakeQuery: supportedModels not modeled'); } - supportedAgents(): never { throw new Error('FakeQuery: supportedAgents not modeled'); } - mcpServerStatus(): never { throw new Error('FakeQuery: mcpServerStatus not modeled'); } + supportedAgents(): never { + if (this._sdk.supportedAgentsResult === undefined) { throw new Error('FakeQuery: supportedAgents not modeled'); } + return Promise.resolve(this._sdk.supportedAgentsResult) as never; + } + mcpServerStatus(): never { + if (this._sdk.mcpServerStatusResult === undefined) { throw new Error('FakeQuery: mcpServerStatus not modeled'); } + return Promise.resolve(this._sdk.mcpServerStatusResult) as never; + } getContextUsage(): never { throw new Error('FakeQuery: getContextUsage not modeled'); } usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(): never { throw new Error('FakeQuery: usage_EXPERIMENTAL not modeled'); } /** Phase 11 — programmable tool-name snapshot returned by `reloadPlugins()`. */ @@ -603,6 +625,7 @@ interface ITestContext { readonly stateManager: AgentHostStateManager; readonly configService: AgentConfigurationService; readonly instantiationService: IInstantiationService; + readonly fileService: IFileService; } /** @@ -642,7 +665,14 @@ function createTestContext( const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + // In-memory file service the session's customization scan / agent-name + // resolution runs against; exposed so tests can seed `.claude/**` files. + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const services = new ServiceCollection( + [IFileService, fileService], + [INativeEnvironmentService, { userHome: URI.file('/mock-home') } as INativeEnvironmentService], [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -655,7 +685,7 @@ function createTestContext( ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); - return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService }; + return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService, fileService }; } /** Drains the microtask queue so awaited refresh writes settle. */ @@ -676,6 +706,22 @@ function stubAgentSdkDownloader(): IAgentSdkDownloader { }; } +/** + * Foundational services every {@link ClaudeAgentSession} requires for its + * customization disk scan: an in-memory {@link IFileService} (nothing is + * seeded under the mock home, so the scan is deterministically empty in + * tests) and a mock {@link INativeEnvironmentService} supplying `userHome`. + * Spread into each test {@link ServiceCollection}. + */ +function claudeFileEnvServices(disposables: Pick): [typeof IFileService | typeof INativeEnvironmentService, IFileService | INativeEnvironmentService][] { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + return [ + [IFileService, fileService], + [INativeEnvironmentService, { userHome: URI.file('/mock-home') } as INativeEnvironmentService], + ]; +} + // #endregion suite('ClaudeAgent', () => { @@ -1343,6 +1389,43 @@ suite('ClaudeAgent', () => { }); }); + test('materialize resolves the SDK agent name from the file frontmatter, not the filename', async () => { + // `_resolveAgentName` parses the selected `~/.claude/agents/.md`: + // the SDK keys agents by their frontmatter `name`, which need not match + // the filename. A selection pointing at `foo.md` whose frontmatter says + // `name: my-real-agent` must start the SDK up with agent=my-real-agent. + const { agent, sdk, fileService } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const agentFile = URI.file('/mock-home/.claude/agents/foo.md'); + await fileService.writeFile(agentFile, VSBuffer.fromString('---\nname: my-real-agent\ndescription: A real agent\n---\nbody')); + + const created = await agent.createSession({ workingDirectory: URI.file('/work'), agent: { uri: agentFile.toString() } }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'my-real-agent'); + }); + + test('materialize resolves a built-in (claude-internal) agent selection to its name', async () => { + // A built-in agent (e.g. `Explore`) has no editable file on disk; it is + // selected via a synthetic `claude-internal:/agent/` URI. Materialize + // must decode the name from the path and start the SDK with agent=Explore + // (the inverse of `nonEditableUri`). + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work'), agent: { uri: 'claude-internal:/agent/Explore' } }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'Explore'); + }); + test('materialize event payload shape — { session, workingDirectory, project: undefined }', async () => { // Phase 6 §5.1 Test 4. Pins the {@link IAgentMaterializeSessionEvent} // payload independently of the tracer in Test 3. The default @@ -2051,6 +2134,7 @@ suite('ClaudeAgent', () => { const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -3197,6 +3281,7 @@ suite('ClaudeAgent', () => { } const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, new NullLogService()], [ICopilotApiService, new FakeCopilotApiService()], [IClaudeProxyService, new RecordingProxyService()], @@ -3248,6 +3333,7 @@ suite('ClaudeAgent', () => { const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -3605,6 +3691,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { } as unknown as IAgentConfigurationService; const sessionData = new RecordingSessionDataService(createSessionDataService()); const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, new NullLogService()], [IAgentConfigurationService, fakeConfigService], [IClaudeAgentSdkService, sdk], @@ -4877,7 +4964,12 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const services = new ServiceCollection( + [IFileService, fileService], + [INativeEnvironmentService, { userHome: URI.file('/mock-home') } as INativeEnvironmentService], [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -4890,7 +4982,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); - return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService }; + return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService, fileService }; } test('setClientCustomizations forwards each item as a SessionCustomizationUpdated action', async () => { @@ -4969,7 +5061,30 @@ suite('ClaudeAgent — Phase 11 customizations', () => { await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); const customizations = await agent.getSessionCustomizations!(created.session); - assert.strictEqual(customizations.length, 1); + // The client-pushed customization, plus the curated read-only built-ins + // always present pre-materialize for discoverability (before a live SDK + // set exists): the built-in agents directory and the "Built-in" skills + // container. + assert.deepStrictEqual(customizations.map(c => c.uri), ['https://a', 'file:///mock-home/.claude/agents', 'agent-builtin:/skills']); + }); + + test('getSessionCustomizations overlays the enablement state onto client-pushed entries', async () => { + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + + await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + // Disable the client-pushed entry; the projection must reflect it. + agent.setCustomizationEnabled(customizationId('https://a'), false); + // Disabling a DISCOVERED entry's id must be a no-op — the enablement + // overlay is applied to the client-pushed tier only. + agent.setCustomizationEnabled(customizationId('agent-builtin:/skills'), false); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.find(c => c.uri === 'https://a')?.enabled, false); + assert.strictEqual(customizations.find(c => c.uri === 'agent-builtin:/skills')?.enabled, true, 'discovered entries are not toggled by the enablement map'); }); test('send pre-flight: dirty customizations triggers a rebind (SDK plugin URI set is captured at startup, so any change must restart the Query)', async () => { @@ -5071,8 +5186,47 @@ suite('ClaudeAgent — Phase 11 customizations', () => { await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); - assert.strictEqual(customizations.length, 1, 'client-pushed projection survives SDK snapshot failure'); - assert.strictEqual(customizations[0].uri, 'https://a'); + // SDK snapshot failed → `sdk` stays undefined → unfiltered fallback: + // the client-pushed entry survives (UI not blanked) and the curated + // built-ins are appended (the built-in agents directory and the skills + // container) since there is no live set to derive from. + assert.deepStrictEqual(customizations.map(c => c.uri), ['https://a', 'file:///mock-home/.claude/agents', 'agent-builtin:/skills'], 'client-pushed projection survives SDK snapshot failure'); + }); + + test('getSessionCustomizations derives the Built-in container from the live SDK command set post-materialize', async () => { + // Once materialized, the runtime's real built-ins are exactly the SDK + // commands we don't discover on disk — surfaced read-only with the + // SDK's own descriptions, replacing the curated pre-materialize seed. + const pm = new FakeAgentPluginManager(); + const { agent, sdk } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // A successful snapshot: one SDK-only command, no agents/MCP. (No disk + // skills exist under /work, so the command becomes a built-in.) + sdk.supportedCommandsResult = [{ name: 'sdkcmd', description: 'Provided by the runtime.', argumentHint: '' }]; + sdk.supportedAgentsResult = []; + sdk.mcpServerStatusResult = []; + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.length, 1); + const container = customizations[0]; + assert.strictEqual(container.type, CustomizationType.Directory); + assert.strictEqual(container.uri, 'agent-builtin:/skills'); + + // The single child is the SDK command (with the SDK's description), + // proving the live command set — not the curated hardcoded list — + // drives the post-materialize built-ins. + const child = container.children?.[0]; + assert.ok(child); + assert.strictEqual(child.type, CustomizationType.Skill); + assert.deepStrictEqual( + { count: container.children?.length, name: child.name, description: child.description }, + { count: 1, name: 'sdkcmd', description: 'Provided by the runtime.' } + ); }); test('changeAgent on a provisional session stashes the selection (no SDK contact) and lands on Options.agent at materialize', async () => { diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts new file mode 100644 index 0000000000000..c51568a5f8865 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CustomizationType } from '../../../common/state/protocol/state.js'; +import { buildClaudeBuiltinSkillsContainer, buildSdkBuiltinSkillsContainer } from '../../../node/claude/customizations/claudeBuiltinCommands.js'; + +/** Black-box copy of the (intentionally unexported) built-in URI scheme. */ +const AGENT_BUILTIN_SCHEME = 'agent-builtin'; + +suite('claudeBuiltinCommands', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('builds a read-only Built-in skills container with agent-builtin children', () => { + const container = buildClaudeBuiltinSkillsContainer(new Set()); + assert.ok(container, 'expected a built-in container'); + + // Container is a non-writable Skill directory on the agent-builtin scheme. + assert.strictEqual(container.type, CustomizationType.Directory); + assert.strictEqual(container.contents, CustomizationType.Skill); + assert.strictEqual(container.writable, false); + assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_SCHEME); + + const children = container.children ?? []; + assert.ok(children.length > 0, 'expected built-in skills'); + for (const child of children) { + const uri = URI.parse(child.uri); + assert.strictEqual(child.type, CustomizationType.Skill); + assert.strictEqual(uri.scheme, AGENT_BUILTIN_SCHEME, `child ${child.name} should use the agent-builtin scheme`); + assert.strictEqual(uri.path, `/skill/${child.name}`, `child ${child.name} should be a /skill/ path`); + assert.ok(child.description && child.description.length > 0, `child ${child.name} should have a description`); + } + + // Names are unique. + const names = children.map(c => c.name); + assert.strictEqual(new Set(names).size, names.length, 'built-in command names should be unique'); + }); + + test('curated container excludes built-ins that collide with a discovered disk skill', () => { + const all = buildClaudeBuiltinSkillsContainer(new Set()); + const allNames = (all?.children ?? []).map(c => c.name); + assert.ok(allNames.includes('init'), 'precondition: `init` is a curated built-in'); + + // A disk skill named `init` must suppress the curated built-in of the + // same name (the editable file wins), so it is never duplicated. + const filtered = buildClaudeBuiltinSkillsContainer(new Set(['init'])); + const filteredNames = (filtered?.children ?? []).map(c => c.name); + assert.ok(!filteredNames.includes('init'), '`init` should be excluded when on disk'); + assert.strictEqual(filteredNames.length, allNames.length - 1); + }); + + test('SDK container surfaces only commands not discovered on disk, carrying SDK descriptions', () => { + const commands = [ + { name: 'init', description: 'Initialize the project.', argumentHint: '' }, + { name: 'my-skill', description: 'A user skill on disk.', argumentHint: '' }, + { name: 'compact', description: 'Compact the context.', argumentHint: '' }, + ]; + const container = buildSdkBuiltinSkillsContainer(commands, new Set(['my-skill'])); + assert.ok(container, 'expected a built-in container'); + + assert.strictEqual(container.contents, CustomizationType.Skill); + assert.strictEqual(container.writable, false); + assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_SCHEME); + + const children = container.children ?? []; + // The on-disk skill is excluded; the two genuine runtime built-ins remain. + const summary = children.map(child => { + assert.strictEqual(child.type, CustomizationType.Skill); + const uri = URI.parse(child.uri); + assert.strictEqual(uri.scheme, AGENT_BUILTIN_SCHEME); + assert.strictEqual(uri.path, `/skill/${child.name}`, `child ${child.name} should be a /skill/ path`); + return { name: child.name, description: child.description }; + }); + assert.deepStrictEqual(summary, [ + { name: 'init', description: 'Initialize the project.' }, + { name: 'compact', description: 'Compact the context.' }, + ]); + }); + + test('SDK container is undefined when every command is a known disk skill', () => { + const commands = [{ name: 'a', description: 'A', argumentHint: '' }]; + assert.strictEqual(buildSdkBuiltinSkillsContainer(commands, new Set(['a'])), undefined); + }); + + test('SDK container dedupes commands by name, keeping the first', () => { + const commands = [ + { name: 'dup', description: 'first', argumentHint: '' }, + { name: 'dup', description: 'second', argumentHint: '' }, + ]; + const container = buildSdkBuiltinSkillsContainer(commands, new Set()); + assert.strictEqual(container?.children?.length, 1); + const child = container?.children?.[0]; + assert.ok(child); + assert.strictEqual(child.type, CustomizationType.Skill); + assert.strictEqual(child.description, 'first'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeCustomizationTestUtils.ts b/src/vs/platform/agentHost/test/node/customizations/claudeCustomizationTestUtils.ts new file mode 100644 index 0000000000000..c0b22b023d425 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeCustomizationTestUtils.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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { FileService } from '../../../../files/common/fileService.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../log/common/log.js'; + +/** The in-memory project (workspace) root the discovery tests scan. */ +export const claudeTestWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); + +/** The in-memory user-home root the discovery tests scan. */ +export const claudeTestUserHome = URI.from({ scheme: Schemas.inMemory, path: '/home' }); + +/** + * Creates a {@link FileService} backed by an in-memory provider for the + * `inmemory` scheme, registering both with `disposables` for cleanup. + */ +export function createInMemoryFileService(disposables: DisposableStore): IFileService { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + return fileService; +} + +/** Writes `content` to the in-memory `path` and returns its URI. */ +export async function seedFile(fileService: IFileService, path: string, content = ''): Promise { + const uri = URI.from({ scheme: Schemas.inMemory, path }); + await fileService.writeFile(uri, VSBuffer.fromString(content)); + return uri; +} diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts deleted file mode 100644 index 2c2153e5cf85a..0000000000000 --- a/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts +++ /dev/null @@ -1,169 +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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { TestInstantiationService } from '../../../../instantiation/test/common/instantiationServiceMock.js'; -import { FileService } from '../../../../files/common/fileService.js'; -import { IFileService } from '../../../../files/common/files.js'; -import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../../log/common/log.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../../common/agentPluginManager.js'; -import { CustomizationLoadStatus, CustomizationType, type ClientPluginCustomization, type Customization } from '../../../common/state/sessionState.js'; -import type { ISdkResolvedCustomizations } from '../../../node/claude/claudeSdkPipeline.js'; -import { ClaudeSdkCustomizationBundler } from '../../../node/claude/customizations/claudeSdkCustomizationBundler.js'; - -suite('ClaudeSdkCustomizationBundler', () => { - - const disposables = new DisposableStore(); - let fileService: FileService; - let bundler: ClaudeSdkCustomizationBundler; - const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); - const workingDir = URI.file('/work'); - - setup(() => { - fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - - const inst = disposables.add(new TestInstantiationService()); - inst.stub(IFileService, fileService); - inst.stub(IAgentPluginManager, { - basePath, - syncCustomizations: async (_clientId: string, _refs: readonly ClientPluginCustomization[]): Promise => [], - } satisfies Partial as unknown as IAgentPluginManager); - bundler = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, workingDir)); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - function snapshot(overrides: Partial = {}): ISdkResolvedCustomizations { - return { - commands: [], - agents: [], - mcpServers: [], - ...overrides, - }; - } - - test('returns undefined when SDK snapshot has no commands or agents', async () => { - const result = await bundler.bundle(snapshot()); - assert.strictEqual(result, undefined); - }); - - test('writes manifest, agent files, and skill subdirs for a snapshot with agents and commands', async () => { - const result = await bundler.bundle(snapshot({ - agents: [{ name: 'planner', description: 'Plans things', model: 'claude' }], - commands: [{ name: 'doit', description: 'Does it', argumentHint: '' }], - })); - - assert.ok(result, 'should produce a bundle'); - const rootUri = URI.parse(result!.uri); - const manifest = await fileService.readFile(URI.joinPath(rootUri, '.plugin', 'plugin.json')); - const manifestJson = JSON.parse(manifest.value.toString()); - assert.strictEqual(manifestJson.name, 'claude-discovered'); - const agentFile = await fileService.readFile(URI.joinPath(rootUri, 'agents', 'planner.md')); - assert.match(agentFile.value.toString(), /name: "planner"/); - assert.match(agentFile.value.toString(), /description: "Plans things"/); - const skillFile = await fileService.readFile(URI.joinPath(rootUri, 'skills', 'doit', 'SKILL.md')); - assert.match(skillFile.value.toString(), /name: "doit"/); - assert.match(skillFile.value.toString(), /Usage: ``/); - }); - - test('children include agent customizations sourced from the SDK snapshot with on-disk file URIs', async () => { - const result = await bundler.bundle(snapshot({ - agents: [ - { name: 'a1', description: 'one', model: 'm' }, - { name: 'a2', description: 'two', model: 'm' }, - ], - })); - const agents = result!.children!.filter(c => c.type === CustomizationType.Agent); - assert.deepStrictEqual(agents.map(a => a.name), ['a1', 'a2']); - assert.ok(agents[0].uri.endsWith('/agents/a1.md'), `expected on-disk path, got ${agents[0].uri}`); - assert.ok(agents[1].uri.endsWith('/agents/a2.md')); - }); - - test('repeated bundle with same snapshot is nonce-stable and does not rewrite', async () => { - const r1 = await bundler.bundle(snapshot({ - agents: [{ name: 'p', description: 'd', model: 'm' }], - })); - const rootUri = URI.parse(r1!.uri); - const agentUri = URI.joinPath(rootUri, 'agents', 'p.md'); - const stat1 = await fileService.stat(agentUri); - - const r2 = await bundler.bundle(snapshot({ - agents: [{ name: 'p', description: 'd', model: 'm' }], - })); - assert.strictEqual(r1!.id, r2!.id); - const stat2 = await fileService.stat(agentUri); - assert.strictEqual(stat1.mtime, stat2.mtime, 'unchanged snapshot must not rewrite the on-disk tree'); - }); - - test('changed snapshot deletes prior bundle tree before writing the new one', async () => { - await bundler.bundle(snapshot({ - agents: [{ name: 'old', description: 'd', model: 'm' }], - })); - const result = await bundler.bundle(snapshot({ - agents: [{ name: 'new', description: 'd', model: 'm' }], - })); - const rootUri = URI.parse(result!.uri); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'new.md'))); - assert.ok(!(await fileService.exists(URI.joinPath(rootUri, 'agents', 'old.md'))), 'previous agent file should be deleted'); - }); - - test('sanitises agent and command names — invalid chars replaced, length capped, empty falls back to "unnamed"', async () => { - const longName = 'a'.repeat(200); - const result = await bundler.bundle(snapshot({ - agents: [ - { name: 'has spaces & slashes/here', description: 'd', model: 'm' }, - { name: longName, description: 'd', model: 'm' }, - { name: '!!!', description: 'd', model: 'm' }, - ], - })); - const rootUri = URI.parse(result!.uri); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'has_spaces___slashes_here.md'))); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', `${'a'.repeat(128)}.md`))); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', '___.md'))); - }); - - test('discoverable bundles for different working directories namespace by hash so they do not collide', async () => { - const inst = disposables.add(new TestInstantiationService()); - inst.stub(IFileService, fileService); - inst.stub(IAgentPluginManager, { - basePath, - } satisfies Partial as unknown as IAgentPluginManager); - const other = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, URI.file('/other-work'))); - - const a = await bundler.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); - const b = await other.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); - assert.notStrictEqual(a!.uri, b!.uri); - }); - - test('returned Customization carries the expected shape (load Loaded, enabled true, name)', async () => { - const result = await bundler.bundle(snapshot({ - agents: [{ name: 'a', description: 'd', model: 'm' }], - commands: [{ name: 'c', description: 'd', argumentHint: '' }], - })); - assert.deepStrictEqual({ - enabled: result!.enabled, - loadKind: result!.load?.kind, - type: result!.type, - }, { - enabled: true, - loadKind: CustomizationLoadStatus.Loaded, - type: CustomizationType.Plugin, - }); - assert.ok(typeof result!.name === 'string' && result!.name.length > 0); - }); - - // Smoke: ensure return type compiles against Customization - function _typeCheck(): Customization | undefined { - return undefined; - } - void _typeCheck; -}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts new file mode 100644 index 0000000000000..050924241de68 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { CustomizationType, McpServerStatus, type AgentSelection, type DirectoryCustomization, type McpServerCustomization } from '../../../common/state/protocol/state.js'; +import { customizationId } from '../../../common/state/sessionState.js'; +import type { ISdkResolvedCustomizations } from '../../../node/claude/claudeSdkPipeline.js'; +import { ClaudeCustomizationWatcher, buildDiscoveredCustomizations, mapDiscoveredCustomizations, resolveClaudeAgentName } from '../../../node/claude/customizations/claudeSessionCustomizationDiscovery.js'; +import { CLAUDE_BUILTIN_AGENTS } from '../../../node/claude/customizations/claudeBuiltinCommands.js'; +import { toParsedAgent, toParsedSkill, type IParsedRule } from '../../../../agentPlugins/common/pluginParsers.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from './claudeCustomizationTestUtils.js'; + +suite('claudeSessionCustomizationDiscovery', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('mapDiscoveredCustomizations', () => { + test('maps discovered entries into per-scope Directory containers with real child URIs + top-level MCP', () => { + const wsAgentUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/agents/wa.md' }); + const wsSkillUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/skills/ws/SKILL.md' }); + const userAgentUri = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/ua.md' }); + const discovered = [ + toParsedAgent({ uri: wsAgentUri, name: 'wa', description: 'WA' }), + toParsedSkill({ uri: wsSkillUri, name: 'ws', description: 'WS' }), + toParsedAgent({ uri: userAgentUri, name: 'ua', description: 'UA' }), + ]; + const mcp: McpServerCustomization[] = [{ type: CustomizationType.McpServer, id: 'mcp-id', uri: 'inmemory:/x', name: 'srv', enabled: true, state: { kind: McpServerStatus.Starting } }]; + + const result = mapDiscoveredCustomizations(discovered, mcp, workspace, userHome); + + const dirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + // Workspace containers first (agents, skills), then user — each rooted at + // the real `/.claude/` dir so the workbench can label scope. + assert.deepStrictEqual( + dirs.map(d => ({ uri: d.uri, contents: d.contents, children: d.children?.map(c => ({ name: c.name, uri: c.uri })) })), + [ + { uri: URI.joinPath(workspace, '.claude', 'agents').toString(), contents: CustomizationType.Agent, children: [{ name: 'wa', uri: wsAgentUri.toString() }] }, + { uri: URI.joinPath(workspace, '.claude', 'skills').toString(), contents: CustomizationType.Skill, children: [{ name: 'ws', uri: wsSkillUri.toString() }] }, + { uri: URI.joinPath(userHome, '.claude', 'agents').toString(), contents: CustomizationType.Agent, children: [{ name: 'ua', uri: userAgentUri.toString() }] }, + ], + ); + + const server = result.find(c => c.type === CustomizationType.McpServer) as McpServerCustomization; + assert.strictEqual(server.name, 'srv'); + }); + + test('maps rules into per-scope Directory containers rooted at `.claude/rules`', () => { + const wsRuleUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/CLAUDE.md' }); + const userRuleUri = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/rules/g.md' }); + const rule = (uri: URI, name: string): IParsedRule => ({ + uri, + name, + customization: { type: CustomizationType.Rule, id: customizationId(uri.toString()), uri: uri.toString(), name, alwaysApply: true }, + }); + const discovered = [rule(wsRuleUri, 'CLAUDE.md'), rule(userRuleUri, 'g')]; + + const result = mapDiscoveredCustomizations(discovered, [], workspace, userHome); + + const dirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + assert.deepStrictEqual( + dirs.map(d => ({ uri: d.uri, contents: d.contents, children: d.children?.map(c => ({ name: c.name, uri: c.uri })) })), + [ + { uri: URI.joinPath(workspace, '.claude', 'rules').toString(), contents: CustomizationType.Rule, children: [{ name: 'CLAUDE.md', uri: wsRuleUri.toString() }] }, + { uri: URI.joinPath(userHome, '.claude', 'rules').toString(), contents: CustomizationType.Rule, children: [{ name: 'g', uri: userRuleUri.toString() }] }, + ], + ); + }); + }); + + suite('buildDiscoveredCustomizations', () => { + test('post-materialize filter keeps SDK-known disk entries, hides unknown, adds non-editable fallback', () => { + const knownAgent = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/known.md' }); + const hiddenAgent = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/hidden.md' }); + const diskSkill = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/skills/kskill/SKILL.md' }); + const discovered = [ + toParsedAgent({ uri: knownAgent, name: 'known', description: 'K' }), + toParsedAgent({ uri: hiddenAgent, name: 'hidden' }), + toParsedSkill({ uri: diskSkill, name: 'kskill' }), + ]; + const diskMcp: McpServerCustomization = { type: CustomizationType.McpServer, id: 'disk-mcp', uri: 'inmemory:/settings.json', name: 'diskmcp', enabled: true, state: { kind: McpServerStatus.Starting } }; + const sdk: ISdkResolvedCustomizations = { + agents: [{ name: 'known', description: 'K' }, { name: 'sdkonly', description: 'S' }, { name: 'general-purpose', description: 'default' }], + commands: [{ name: 'kskill', description: '', argumentHint: '' }, { name: 'sdkcmd', description: 'C', argumentHint: '' }], + mcpServers: [{ name: 'diskmcp', status: 'connected' }, { name: 'sdkmcp', status: 'failed' }], + }; + + const result = buildDiscoveredCustomizations(discovered, [diskMcp], workspace, userHome, sdk); + + // Children are split into per-scope containers; aggregate across them. + const dirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + const agentChildren = dirs.filter(d => d.contents === CustomizationType.Agent).flatMap(d => d.children ?? []); + // Editable disk-skill directories only (exclude the read-only Built-in container). + const skillChildren = dirs.filter(d => d.contents === CustomizationType.Skill && URI.parse(d.uri).scheme !== 'agent-builtin').flatMap(d => d.children ?? []); + const mcps = result.filter(c => c.type === CustomizationType.McpServer) as McpServerCustomization[]; + + // known agent kept with its real URI; hidden dropped; sdkonly added + // non-editable; general-purpose default agent hidden. + assert.deepStrictEqual(agentChildren.map(c => ({ name: c.name, uri: c.uri })).sort((a, b) => a.name.localeCompare(b.name)), [ + { name: 'known', uri: knownAgent.toString() }, + { name: 'sdkonly', uri: 'claude-internal:/agent/sdkonly' }, + ]); + // disk skill kept (real URI); the SDK-only command `sdkcmd` is NOT mixed + // in among the editable disk skills (it surfaces in the Built-in container). + assert.deepStrictEqual(skillChildren.map(c => ({ name: c.name, uri: c.uri })), [ + { name: 'kskill', uri: diskSkill.toString() }, + ]); + // disk MCP kept + enriched to Ready (connected); SDK-only MCP added. + assert.deepStrictEqual(mcps.map(m => ({ name: m.name, state: m.state.kind })).sort((a, b) => a.name.localeCompare(b.name)), [ + { name: 'diskmcp', state: McpServerStatus.Ready }, + { name: 'sdkmcp', state: McpServerStatus.Starting }, + ]); + }); + + test('SDK-only commands surface read-only in the Built-in container, not among editable disk skills', () => { + const diskSkill = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/skills/real/SKILL.md' }); + const discovered = [toParsedSkill({ uri: diskSkill, name: 'real' })]; + const sdk: ISdkResolvedCustomizations = { + agents: [], + // `real` is a loaded disk skill; `init` / `loop` are SDK-only + // built-in slash commands with no editable file. + commands: [ + { name: 'real', description: '', argumentHint: '' }, + { name: 'init', description: 'Initialize', argumentHint: '' }, + { name: 'loop', description: 'Loop', argumentHint: '' }, + ], + mcpServers: [], + }; + + const result = buildDiscoveredCustomizations(discovered, [], workspace, userHome, sdk); + const skillDirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + + // Editable disk-skill directories contain only the real on-disk skill. + const editableSkills = skillDirs + .filter(d => d.contents === CustomizationType.Skill && URI.parse(d.uri).scheme !== 'agent-builtin') + .flatMap(d => d.children ?? []); + assert.deepStrictEqual(editableSkills.map(c => ({ name: c.name, uri: c.uri })), [{ name: 'real', uri: diskSkill.toString() }]); + + // The SDK-only commands appear in the read-only Built-in container. + const builtin = skillDirs.find(d => URI.parse(d.uri).scheme === 'agent-builtin'); + assert.deepStrictEqual((builtin?.children ?? []).map(c => c.name), ['init', 'loop']); + }); + + test('rules survive the post-materialize SDK filter (no SDK counterpart)', () => { + const ruleUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/CLAUDE.md' }); + const discovered: IParsedRule[] = [{ + uri: ruleUri, + name: 'CLAUDE.md', + customization: { type: CustomizationType.Rule, id: customizationId(ruleUri.toString()), uri: ruleUri.toString(), name: 'CLAUDE.md', alwaysApply: true }, + }]; + const sdk: ISdkResolvedCustomizations = { agents: [], commands: [], mcpServers: [] }; + + const result = buildDiscoveredCustomizations(discovered, [], workspace, userHome, sdk); + + const ruleChildren = (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .filter(d => d.contents === CustomizationType.Rule) + .flatMap(d => d.children ?? []); + assert.deepStrictEqual(ruleChildren.map(c => ({ name: c.name, uri: c.uri })), [{ name: 'CLAUDE.md', uri: ruleUri.toString() }]); + }); + + test('pre-materialize seeds curated built-in agents (claude-internal; general-purpose hidden; disk name wins)', () => { + // A disk agent named 'Explore' must shadow the curated built-in of the + // same name (the editable file wins). + const diskExplore = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/Explore.md' }); + const discovered = [toParsedAgent({ uri: diskExplore, name: 'Explore', description: 'mine' })]; + + const result = buildDiscoveredCustomizations(discovered, [], workspace, userHome, undefined); + + const agentChildren = (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .filter(d => d.contents === CustomizationType.Agent) + .flatMap(d => d.children ?? []); + + // Disk Explore kept (real URI); every curated built-in EXCEPT + // general-purpose (hidden) and the disk-shadowed Explore is added as a + // non-editable `claude-internal:` entry. + const expected = [ + { name: 'Explore', uri: diskExplore.toString() }, + ...CLAUDE_BUILTIN_AGENTS + .map(a => a.name) + .filter(n => n !== 'general-purpose' && n !== 'Explore') + .map(n => ({ name: n, uri: `claude-internal:/agent/${n}` })), + ].sort((a, b) => a.name.localeCompare(b.name)); + + assert.deepStrictEqual( + agentChildren.map(c => ({ name: c.name, uri: c.uri })).sort((a, b) => a.name.localeCompare(b.name)), + expected, + ); + }); + }); + + suite('ClaudeCustomizationWatcher', () => { + test('fires once (debounced) for changes under watched roots and ignores unrelated edits', async () => { + const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), 5)); + let fires = 0; + disposables.add(watcher.onDidChange(() => { fires++; })); + + // An unrelated edit in the workspace root must NOT trigger a refresh. + await seed('/workspace/unrelated.txt', 'x'); + await timeout(40); + assert.strictEqual(fires, 0); + + // A burst of edits across the watched roots collapses to a single fire: + // a user-scope agent, a project-scope skill, and the sibling `.mcp.json`. + await seed('/home/.claude/agents/a.md', 'a'); + await seed('/workspace/.claude/skills/s/SKILL.md', 's'); + await seed('/workspace/.mcp.json', '{}'); + await timeout(40); + assert.strictEqual(fires, 1); + }); + + test('fires for a root-level CLAUDE.md / CLAUDE.local.md edit', async () => { + const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), 5)); + let fires = 0; + disposables.add(watcher.onDidChange(() => { fires++; })); + + await seed('/workspace/CLAUDE.md', '# memory'); + await seed('/workspace/CLAUDE.local.md', '# personal'); + await timeout(40); + assert.strictEqual(fires, 1); + }); + }); + + suite('resolveClaudeAgentName', () => { + const log = new NullLogService(); + const sel = (uri: string): AgentSelection => ({ uri }); + + test('no selection → undefined', async () => { + assert.strictEqual(await resolveClaudeAgentName(undefined, fileService, log, 'sid'), undefined); + }); + + test('claude-internal URI decodes the name (inverse of nonEditableUri)', async () => { + assert.strictEqual(await resolveClaudeAgentName(sel('claude-internal:/agent/sdkonly'), fileService, log, 'sid'), 'sdkonly'); + assert.strictEqual(await resolveClaudeAgentName(sel('claude-internal:/agent/two%20words'), fileService, log, 'sid'), 'two words'); + }); + + test('file URI resolves the frontmatter name (not the filename)', async () => { + const file = await seed('/home/.claude/agents/foo.md', '---\nname: my-real-agent\ndescription: d\n---\nbody'); + assert.strictEqual(await resolveClaudeAgentName(sel(file.toString()), fileService, log, 'sid'), 'my-real-agent'); + }); + + test('unreadable file URI falls back to the basename (minus .md)', async () => { + const missing = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/missing.md' }); + assert.strictEqual(await resolveClaudeAgentName(sel(missing.toString()), fileService, log, 'sid'), 'missing'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts deleted file mode 100644 index dc334735297ea..0000000000000 --- a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts +++ /dev/null @@ -1,78 +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 { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; -import { CustomizationLoadStatus, CustomizationType, customizationId, type Customization } from '../../../common/state/sessionState.js'; -import { projectSessionCustomizations } from '../../../node/claude/customizations/claudeSessionCustomizationsProjector.js'; - -function client(uri: string, enabled = true): ISyncedCustomization { - return { - customization: { - type: CustomizationType.Plugin, - id: customizationId(uri), - uri, - name: uri, - enabled, - load: { kind: CustomizationLoadStatus.Loaded }, - }, - }; -} - -function discoveredBundle(uri: string): Customization { - return { - type: CustomizationType.Plugin, - id: customizationId(uri), - uri, - name: 'VS Code Synced Data', - enabled: true, - load: { kind: CustomizationLoadStatus.Loaded }, - }; -} - -suite('projectSessionCustomizations', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('returns only client-pushed entries when no discovery bundle', () => { - const result = projectSessionCustomizations([client('https://a')], new Map(), undefined); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri.toString(), 'https://a'); - assert.strictEqual(result[0].enabled, true); - }); - - test('overlays enablement map (keyed by id) on client-pushed entries', () => { - const result = projectSessionCustomizations( - [client('https://a'), client('https://b')], - new Map([[customizationId('https://a'), false]]), - undefined, - ); - assert.strictEqual(result.find(c => c.uri.toString() === 'https://a')?.enabled, false); - assert.strictEqual(result.find(c => c.uri.toString() === 'https://b')?.enabled, true); - }); - - test('appends the discovery bundle verbatim', () => { - const bundleUri = URI.file('/tmp/host-discovery/x').toString(); - const result = projectSessionCustomizations( - [client('https://a')], - new Map(), - discoveredBundle(bundleUri), - ); - assert.strictEqual(result.length, 2); - assert.strictEqual(result[1].uri.toString(), bundleUri); - assert.strictEqual(result[1].enabled, true); - }); - - test('discovery bundle enablement is not overlaid from the map', () => { - const bundleUri = URI.file('/tmp/host-discovery/x').toString(); - const result = projectSessionCustomizations( - [], - new Map([[customizationId(bundleUri), false]]), - discoveredBundle(bundleUri), - ); - assert.strictEqual(result[0].enabled, true); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeAgentSkillScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeAgentSkillScan.test.ts new file mode 100644 index 0000000000000..4722ba5c3382b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeAgentSkillScan.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { CustomizationType } from '../../../../common/state/protocol/state.js'; +import { scanClaudeDiskCustomizations } from '../../../../node/claude/customizations/scan/claudeAgentSkillScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeAgentSkillScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('scans agents, skills, and commands (commands folded into skills) with real URIs', async () => { + const agent = await seed('/home/.claude/agents/a.md', '---\nname: a-agent\ndescription: Agent A\n---\nbody'); + const skill = await seed('/workspace/.claude/skills/s/SKILL.md', '---\nname: s-skill\ndescription: Skill S\n---\nbody'); + // Slash commands are a variant of skills (spec §3) — discovered as Skill kind. + const command = await seed('/workspace/.claude/commands/c.md', '---\nname: c-cmd\ndescription: Command C\n---\nbody'); + + const discovered = await scanClaudeDiskCustomizations(workspace, userHome, fileService); + const actual = discovered + .map(d => ({ type: d.customization.type, uri: d.uri.toString(), name: d.name, description: d.description })) + .sort((a, b) => a.uri.localeCompare(b.uri)); + + assert.deepStrictEqual(actual, [ + { type: CustomizationType.Agent, uri: agent.toString(), name: 'a-agent', description: 'Agent A' }, + { type: CustomizationType.Skill, uri: command.toString(), name: 'c-cmd', description: 'Command C' }, + { type: CustomizationType.Skill, uri: skill.toString(), name: 's-skill', description: 'Skill S' }, + ].sort((a, b) => a.uri.localeCompare(b.uri))); + }); + + test('a skill wins over a same-named command (spec §3 priority)', async () => { + const skill = await seed('/workspace/.claude/skills/dup/SKILL.md', '---\nname: dup\ndescription: The skill\n---\nbody'); + await seed('/workspace/.claude/commands/dup.md', '---\nname: dup\ndescription: The command\n---\nbody'); + + const discovered = await scanClaudeDiskCustomizations(workspace, userHome, fileService); + + assert.deepStrictEqual( + discovered.map(d => ({ type: d.customization.type, uri: d.uri.toString(), name: d.name, description: d.description })), + [{ type: CustomizationType.Skill, uri: skill.toString(), name: 'dup', description: 'The skill' }], + ); + }); + + test('project scope shadows user scope on name clash', async () => { + const projectAgent = await seed('/workspace/.claude/agents/dup.md', '---\nname: dup\ndescription: project\n---\nbody'); + await seed('/home/.claude/agents/dup.md', '---\nname: dup\ndescription: user\n---\nbody'); + + const discovered = await scanClaudeDiskCustomizations(workspace, userHome, fileService); + const dup = discovered.filter(d => d.name === 'dup'); + + assert.strictEqual(dup.length, 1); + assert.strictEqual(dup[0].uri.toString(), projectAgent.toString()); + assert.strictEqual(dup[0].description, 'project'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeMcpScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeMcpScan.test.ts new file mode 100644 index 0000000000000..870a6043ba125 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeMcpScan.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { CustomizationType, McpServerStatus } from '../../../../common/state/protocol/state.js'; +import { scanClaudeMcpServers } from '../../../../node/claude/customizations/scan/claudeMcpScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeMcpScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('scans MCP servers from settings.json with real URIs, ignoring unrelated settings keys', async () => { + const settings = await seed('/workspace/.claude/settings.json', JSON.stringify({ + model: 'claude-x', + permissions: { allow: [] }, + mcpServers: { srv: { command: 'node', args: ['s.js'] } }, + })); + + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + + assert.deepStrictEqual( + servers.map(s => ({ type: s.type, uri: s.uri, name: s.name, enabled: s.enabled, state: s.state })), + [{ type: CustomizationType.McpServer, uri: settings.toString(), name: 'srv', enabled: true, state: { kind: McpServerStatus.Starting } }], + ); + }); + + test('scans a flat .mcp.json server map', async () => { + const mcp = await seed('/workspace/.mcp.json', JSON.stringify({ flatSrv: { command: 'x' } })); + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + assert.deepStrictEqual(servers.map(s => ({ uri: s.uri, name: s.name })), [{ uri: mcp.toString(), name: 'flatSrv' }]); + }); + + test('settings.json without an mcpServers block yields no servers', async () => { + await seed('/workspace/.claude/settings.json', JSON.stringify({ model: 'x', permissions: { allow: [] } })); + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + assert.deepStrictEqual(servers, []); + }); + + test('ignores array-valued MCP entries', async () => { + await seed('/workspace/.mcp.json', JSON.stringify({ bad: [1, 2], good: { command: 'x' } })); + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + assert.deepStrictEqual(servers.map(s => s.name), ['good']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeRuleScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeRuleScan.test.ts new file mode 100644 index 0000000000000..ff48366a7a4b0 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeRuleScan.test.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { CustomizationType } from '../../../../common/state/protocol/state.js'; +import { scanClaudeRules } from '../../../../node/claude/customizations/scan/claudeRuleScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeRuleScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('CLAUDE.md memory files (project + user) become always-on rules with the file name', async () => { + const userMem = await seed('/home/.claude/CLAUDE.md', '# user memory'); + const rootMem = await seed('/workspace/CLAUDE.md', '# project memory'); + const dotMem = await seed('/workspace/.claude/CLAUDE.md', '# project .claude memory'); + const localMem = await seed('/workspace/CLAUDE.local.md', '# personal memory'); + + const rules = await scanClaudeRules(workspace, userHome, fileService); + + assert.deepStrictEqual( + rules + .map(r => ({ uri: r.uri.toString(), name: r.name, type: r.customization.type, alwaysApply: r.customization.alwaysApply, globs: r.customization.globs })) + .sort((a, b) => a.uri.localeCompare(b.uri)), + [ + { uri: dotMem.toString(), name: 'CLAUDE.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + { uri: localMem.toString(), name: 'CLAUDE.local.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + { uri: rootMem.toString(), name: 'CLAUDE.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + { uri: userMem.toString(), name: 'CLAUDE.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + ].sort((a, b) => a.uri.localeCompare(b.uri)), + ); + }); + + test('.claude/rules files are path-scoped when `paths` frontmatter is present, else always-on', async () => { + const scoped = await seed('/workspace/.claude/rules/scoped.md', '---\nname: scoped-rule\ndescription: Only for src\npaths:\n - "src/**"\n - "lib/**"\n---\nbody'); + const always = await seed('/workspace/.claude/rules/always.md', '# applies everywhere'); + + const rules = await scanClaudeRules(workspace, userHome, fileService); + + assert.deepStrictEqual( + rules + .map(r => ({ uri: r.uri.toString(), name: r.name, description: r.customization.description, alwaysApply: r.customization.alwaysApply, globs: r.customization.globs })) + .sort((a, b) => a.uri.localeCompare(b.uri)), + [ + { uri: always.toString(), name: 'always', description: undefined, alwaysApply: true, globs: undefined }, + { uri: scoped.toString(), name: 'scoped-rule', description: 'Only for src', alwaysApply: false, globs: ['src/**', 'lib/**'] }, + ].sort((a, b) => a.uri.localeCompare(b.uri)), + ); + }); + + test('recurses into .claude/rules subdirectories for both scopes', async () => { + const projectNested = await seed('/workspace/.claude/rules/frontend/ui.md', '# ui rule'); + const userRule = await seed('/home/.claude/rules/global.md', '# global rule'); + + const rules = await scanClaudeRules(workspace, userHome, fileService); + + assert.deepStrictEqual( + rules.map(r => r.uri.toString()).sort(), + [projectNested.toString(), userRule.toString()].sort(), + ); + }); +}); diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index caa42eec52ace..045e7a0e40e14 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -252,7 +252,15 @@ function makeHookCustomization(hookUri: URI): HookCustomization { }; } -function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization { +/** + * Builds the protocol {@link McpServerCustomization} for an MCP server + * declared at `definitionUri` (the manifest / settings / `.mcp.json` file + * the server is defined in). The id is disambiguated by server `name` so + * multiple servers declared in one file get distinct ids, and the entry + * carries {@link DEFAULT_MCP_APP} so MCP App support is advertised + * consistently with every other MCP customization. + */ +export function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization { return { type: CustomizationType.McpServer, id: buildChildId(definitionUri, `mcp=${encodeURIComponent(name)}`), @@ -1159,11 +1167,13 @@ export async function parsePlugin( }; } -function toParsedAgent(resource: INamedPluginResource): IParsedAgent { +/** Pairs an agent {@link INamedPluginResource} with its protocol-level {@link AgentCustomization}. */ +export function toParsedAgent(resource: INamedPluginResource): IParsedAgent { return { ...resource, customization: makeAgentCustomization(resource) }; } -function toParsedSkill(resource: INamedPluginResource): IParsedSkill { +/** Pairs a skill {@link INamedPluginResource} with its protocol-level {@link SkillCustomization}. */ +export function toParsedSkill(resource: INamedPluginResource): IParsedSkill { return { ...resource, customization: makeSkillCustomization(resource) }; } diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index cb6728a193332..487bfc3e9d6b4 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -8,17 +8,22 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import { CustomizationType, McpServerStatus, type McpServerCustomization } from '../../../agentHost/common/state/protocol/state.js'; +import { DEFAULT_MCP_APP } from '../../../agentHost/common/state/protocol/mcpAppDefaults.js'; +import { customizationId } from '../../../agentHost/common/state/sessionState.js'; function stubMcpCustomization(): McpServerCustomization { return { type: CustomizationType.McpServer, id: 'stub', uri: 'file:///plugin', name: 'test', enabled: true, state: { kind: McpServerStatus.Starting } }; } import { IParsedHookCommand, + makeMcpServerCustomization, parseComponentPathConfig, resolveComponentDirs, normalizeMcpServerConfiguration, shellQuotePluginRootInCommand, convertBareEnvVarsToVsCodeSyntax, + toParsedAgent, + toParsedSkill, } from '../../common/pluginParsers.js'; suite('pluginParsers', () => { @@ -330,4 +335,61 @@ suite('pluginParsers', () => { assert.strictEqual(IParsedHookCommand.isEquals(left, right), false); }); }); + + suite('toParsedAgent / toParsedSkill', () => { + + test('toParsedAgent pairs the resource with an AgentCustomization', () => { + const uri = URI.file('/home/.claude/agents/explore.md'); + const parsed = toParsedAgent({ uri, name: 'explore', description: 'Explore the codebase' }); + assert.deepStrictEqual(parsed, { + uri, + name: 'explore', + description: 'Explore the codebase', + customization: { + type: CustomizationType.Agent, + id: customizationId(uri.toString()), + uri: uri.toString(), + name: 'explore', + description: 'Explore the codebase', + }, + }); + }); + + test('toParsedSkill pairs the resource with a SkillCustomization and omits an absent description', () => { + const uri = URI.file('/home/.claude/skills/mapper/SKILL.md'); + const parsed = toParsedSkill({ uri, name: 'mapper' }); + assert.deepStrictEqual(parsed, { + uri, + name: 'mapper', + customization: { + type: CustomizationType.Skill, + id: customizationId(uri.toString()), + uri: uri.toString(), + name: 'mapper', + }, + }); + }); + }); + + suite('makeMcpServerCustomization', () => { + + test('builds a Starting server with DEFAULT_MCP_APP and a name-disambiguated id', () => { + const uri = URI.file('/workspace/.mcp.json'); + const customization = makeMcpServerCustomization(uri, 'fs server'); + assert.deepStrictEqual(customization, { + type: CustomizationType.McpServer, + id: `${customizationId(uri.toString())}#mcp=${encodeURIComponent('fs server')}`, + uri: uri.toString(), + name: 'fs server', + enabled: true, + state: { kind: McpServerStatus.Starting }, + mcpApp: DEFAULT_MCP_APP, + }); + }); + + test('two servers declared in the same file get distinct ids', () => { + const uri = URI.file('/workspace/.mcp.json'); + assert.notStrictEqual(makeMcpServerCustomization(uri, 'a').id, makeMcpServerCustomization(uri, 'b').id); + }); + }); }); From ba06c024f8b8b97ee3b860ad2ac828cce687c9ca Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Tue, 23 Jun 2026 21:00:28 +0500 Subject: [PATCH 034/696] inline edit: remove noisy outcome transition console warning (#322560) Removes the '[InlineEditRequestLogContext] outcome transition from ... to ...' console.warn that polluted the extension host console on benign no-op outcome self-transitions (e.g. 'skipped' -> 'skipped') during normal NES usage. Fixes #322547 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../inlineEdits/common/inlineEditLogContext.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts index ce942ca92c872..8b5ee481c9248 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts @@ -412,18 +412,7 @@ export class InlineEditRequestLogContext { private _outcome: LogContextOutcome = 'pending'; - /** - * Sets the outcome, warning if already set (i.e., not `pending`). - * Use direct `this._outcome = ...` assignment to bypass the guard - * (e.g., in `setIsCachedResult` which intentionally overrides any inherited outcome). - */ private _setOutcome(outcome: LogContextOutcome): void { - // 'reusedInFlight' is an intermediate state set when joining an in-flight - // request (before the result arrives), so it can legitimately transition - // to the final outcome (skipped, errored, etc.) just like 'pending'. - if (this._outcome !== 'pending' && this._outcome !== 'reusedInFlight') { - console.warn(`[InlineEditRequestLogContext] outcome transition from '${this._outcome}' to '${outcome}' (request #${this.requestId})`); - } this._outcome = outcome; } @@ -465,9 +454,7 @@ export class InlineEditRequestLogContext { } public markAsPreviouslyRejected() { - // Direct assignment — bypasses _setOutcome guard because this transition - // legitimately overrides 'succeeded' when a fetched edit turns out to be rejected. - this._outcome = 'previouslyRejected'; + this._setOutcome('previouslyRejected'); this._isVisible = true; this.fireDidChange(); } From 51bd587fe5941acf3cd22181c891292809c9a49e Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 24 Jun 2026 02:16:15 +1000 Subject: [PATCH 035/696] Enhance chat model to support legacy model configuration format and update tests for backward compatibility (#322515) * Enhance chat model to support legacy model configuration format and update tests for backward compatibility * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/common/model/chatModel.ts | 23 ++++++++-- .../common/model/chatSessionOperationLog.ts | 3 +- .../chat/test/common/model/chatModel.test.ts | 43 ++++++++++++++++++- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 97239a598ebbe..6db4e28381791 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1901,14 +1901,29 @@ export interface ISerializableChatModelInputState { selectedModel: { identifier: string; metadata: ILanguageModelChatMetadata; + /** + * Configuration (e.g. context size, thinking effort) for the selected + * model, captured so it can be restored alongside the model when the + * session is reopened. + */ + modelConfiguration?: IStringDictionary; } | undefined; - modelConfiguration?: IStringDictionary; inputText: string; selections: ISelection[]; permissionLevel?: ChatPermissionLevel; contrib: Record; } +/** + * Legacy shape of {@link ISerializableChatModelInputState} as persisted by older + * versions, where the selected model's configuration was stored as a sibling + * `modelConfiguration` field instead of nested inside `selectedModel`. Retained + * so sessions serialized in the old format can still be read. + */ +interface ILegacySerializableChatModelInputState extends ISerializableChatModelInputState { + modelConfiguration?: IStringDictionary; +} + /** * Chat data that has been parsed and normalized to the current format. */ @@ -2126,9 +2141,9 @@ class InputModel implements IInputModel { mode: value.mode, selectedModel: value.selectedModel ? { identifier: value.selectedModel.identifier, - metadata: value.selectedModel.metadata + metadata: value.selectedModel.metadata, + modelConfiguration: value.modelConfiguration } : undefined, - modelConfiguration: value.modelConfiguration, inputText: value.inputText, selections: value.selections, permissionLevel: value.permissionLevel, @@ -2441,7 +2456,7 @@ export class ChatModel extends Disposable implements IChatModel { identifier: serializedInputState.selectedModel.identifier, metadata: serializedInputState.selectedModel.metadata }, - modelConfiguration: serializedInputState.modelConfiguration, + modelConfiguration: serializedInputState.selectedModel ? (serializedInputState.selectedModel.modelConfiguration ?? (serializedInputState as ILegacySerializableChatModelInputState).modelConfiguration) : undefined, contrib: serializedInputState.contrib, inputText: serializedInputState.inputText, selections: serializedInputState.selections, diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 7eb6300efc52d..3b92a3d19abb5 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -170,8 +170,7 @@ const requestSchema = Adapt.object({ attachments: Adapt.v(i => i.attachments.map(IChatRequestVariableEntry.toExport), objectsEqual), mode: Adapt.v(i => i.mode, (a, b) => a.id === b.id), - selectedModel: Adapt.v(i => i.selectedModel, (a, b) => a?.identifier === b?.identifier), - modelConfiguration: Adapt.v(i => i.modelConfiguration, objectsEqual), + selectedModel: Adapt.v(i => i.selectedModel, (a, b) => a?.identifier === b?.identifier && objectsEqual(a?.modelConfiguration, b?.modelConfiguration)), inputText: Adapt.v(i => i.inputText), selections: Adapt.v(i => i.selections, objectsEqual), permissionLevel: Adapt.v(i => i.permissionLevel), diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index 17455ee542fe6..dd7d97e884442 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -26,7 +26,7 @@ import { TestExtensionService, TestStorageService } from '../../../../../test/co import { CellUri } from '../../../../notebook/common/notebookCommon.js'; import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, IChatRequestFileEntry, StringChatContextValue } from '../../../common/attachments/chatVariableEntries.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatModel, ChatRequestModel, ChatResponseResource, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response, serializeSendOptions } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatRequestModel, ChatResponseResource, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, ISerializableChatModelInputState, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response, serializeSendOptions } from '../../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; import { ChatRequestQueueKind, IChatService, IChatTerminalToolInvocationData, IChatToolInvocation, ResponseModelState } from '../../../common/chatService/chatService.js'; @@ -343,6 +343,47 @@ suite('ChatModel', () => { assert.strictEqual(exported.requests.length, 1); assert.deepStrictEqual(exported.requests[0].modeInfo, modeInfo); }); + + test('restores legacy top-level modelConfiguration into selectedModel (backwards compat)', async () => { + const legacyConfig = { thinkingEffort: 'high', contextSize: 2000 }; + + // Old format: modelConfiguration was persisted as a sibling of selectedModel + // rather than nested inside it. + const legacyInputState = { + attachments: [], + contrib: {}, + inputText: 'draft', + selections: [], + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: { identifier: 'copilot/gpt', metadata: { name: 'GPT' } }, + modelConfiguration: legacyConfig, + }; + + const serializableData: ISerializableChatData3 = { + version: 3, + sessionId: 'legacy-model-config-session', + creationDate: Date.now(), + customTitle: undefined, + initialLocation: ChatAgentLocation.Chat, + responderUsername: 'bot', + requests: [], + inputState: legacyInputState as unknown as ISerializableChatModelInputState, + }; + + const model = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: serializableData, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + + // Legacy config is recovered into the in-memory input state... + assert.deepStrictEqual(model.inputModel.state.get()?.modelConfiguration, legacyConfig); + + // ...and re-serializes into the new nested shape with no top-level field. + const serialized = model.inputModel.toJSON(); + assert.deepStrictEqual(serialized?.selectedModel?.modelConfiguration, legacyConfig); + assert.strictEqual((serialized as { modelConfiguration?: unknown }).modelConfiguration, undefined); + }); }); suite('Response', () => { From c521ed91382b98c523972d33112ebee57f525e6d Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:38:32 +0200 Subject: [PATCH 036/696] sessions: add pull request pill to session header (#322553) Surface a clickable # pill in the session header meta row when a session is associated with a GitHub pull request. Activating it opens the PR on GitHub (externally). The pill sits between the workspace folder label and the diff-stats pill, with separators shown only when adjacent items are present. Also adds component explorer fixtures covering the header's states (with/without PR, draft PR, in-progress, needs-input, long title, no workspace) in both dark and light themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/media/chatCompositeBar.css | 23 ++ .../sessions/browser/parts/sessionHeader.ts | 65 ++++- .../sessions/sessionHeader.fixture.ts | 228 ++++++++++++++++++ 3 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 8db975354cd1f..e47832775b010 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -234,6 +234,29 @@ color: var(--vscode-chat-linesRemovedForeground); } +/* Pull request pill rendered next to the workspace label in the meta row. */ +.chat-composite-bar-meta-pr { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: inherit; + font: inherit; + font-variant-numeric: tabular-nums; + cursor: pointer; + padding: 0 2px; + border-radius: var(--vscode-cornerRadius-small); +} + +.chat-composite-bar-meta-pr:hover { + color: inherit; + text-decoration: none; +} + +.chat-composite-bar-meta-pr:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + /* Tabs row */ .chat-composite-bar-tabs-row { display: flex; diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index 780da3fefd807..9a1cac331bde2 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -14,7 +14,9 @@ import { autorun, IObservable, IReader, observableSignalFromEvent } from '../../ import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { Codicon } from '../../../base/common/codicons.js'; import { ThemeIcon } from '../../../base/common/themables.js'; +import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; +import { IOpenerService } from '../../../platform/opener/common/opener.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsListModelService } from '../../services/sessions/browser/sessionsListModelService.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; @@ -76,6 +78,8 @@ export class SessionHeader extends Disposable { private readonly _titleTextEl: HTMLElement; private readonly _metaRow: HTMLElement; private readonly _metaWorkspaceEl: HTMLElement; + private readonly _metaPrSeparatorEl: HTMLElement; + private readonly _metaPrEl: HTMLElement; private readonly _metaSeparatorEl: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; private readonly _metaToolbar: MenuWorkbenchToolBar; @@ -86,6 +90,9 @@ export class SessionHeader extends Disposable { private _renameInput: HTMLInputElement | undefined; private _session: IActiveSession | undefined; + /** The pull request currently surfaced in the meta row, used by the click handler. */ + private _currentPullRequestUri: URI | undefined; + private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -122,6 +129,7 @@ export class SessionHeader extends Disposable { @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, @ISessionsService private readonly _sessionsService: ISessionsService, @IHoverService private readonly _hoverService: IHoverService, + @IOpenerService private readonly _openerService: IOpenerService, ) { super(); @@ -195,6 +203,37 @@ export class SessionHeader extends Disposable { () => this._buildWorkspaceHover(), )); + // Pull request pill: a `#` link rendered next to the workspace label + // when the session is associated with a GitHub pull request. Activating it + // opens the pull request on GitHub. The leading separator is shown only when + // both the workspace label and the pill are visible. + this._metaPrSeparatorEl = $('span.chat-composite-bar-meta-separator'); + this._metaRow.appendChild(this._metaPrSeparatorEl); + + this._metaPrEl = $('a.chat-composite-bar-meta-pr'); + this._metaPrEl.setAttribute('role', 'button'); + this._metaPrEl.tabIndex = 0; + this._metaRow.appendChild(this._metaPrEl); + + this._register(this._hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + this._metaPrEl, + () => this._currentPullRequestUri ? localize('agentSessions.openPullRequest', "Open Pull Request") : undefined, + )); + + this._register(addDisposableListener(this._metaPrEl, EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + this._openPullRequest(); + })); + this._register(addStandardDisposableListener(this._metaPrEl, EventType.KEY_DOWN, (e: IKeyboardEvent) => { + if (e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space) { + e.preventDefault(); + e.stopPropagation(); + this._openPullRequest(); + } + })); + this._metaSeparatorEl = $('span.chat-composite-bar-meta-separator'); this._metaRow.appendChild(this._metaSeparatorEl); @@ -357,14 +396,25 @@ export class SessionHeader extends Disposable { } this._metaWorkspaceEl.style.display = hasWorkspace ? '' : 'none'; + // Pull request pill — surface `#` when the session's repository has an + // associated GitHub pull request. Clicking it opens the PR on GitHub. + const pullRequest = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest; + this._currentPullRequestUri = pullRequest?.uri; + const hasPullRequest = !!pullRequest; + if (hasPullRequest) { + reset(this._metaPrEl, $('span.chat-composite-bar-meta-pr-label', undefined, `#${pullRequest.number}`)); + } + this._metaPrEl.style.display = hasPullRequest ? '' : 'none'; + this._metaPrSeparatorEl.style.display = hasWorkspace && hasPullRequest ? '' : 'none'; + // Show the meta row separator/row based on whether the meta toolbar has any // contributed actions. Reading the signal re-runs this on menu changes. this._metaActionsSignal.read(reader); const hasMetaActions = !this._metaToolbar.isEmpty(); - this._metaSeparatorEl.style.display = hasWorkspace && hasMetaActions ? '' : 'none'; + this._metaSeparatorEl.style.display = (hasWorkspace || hasPullRequest) && hasMetaActions ? '' : 'none'; - this._metaRow.style.display = hasWorkspace || hasMetaActions ? '' : 'none'; + this._metaRow.style.display = hasWorkspace || hasPullRequest || hasMetaActions ? '' : 'none'; this._onDidChangeHeight.fire(); } @@ -399,6 +449,17 @@ export class SessionHeader extends Disposable { return { markdown: md, markdownNotSupportedFallback: fallbackLines.join('\n') }; } + /** + * Opens the pull request currently surfaced in the meta row on GitHub. + */ + private _openPullRequest(): void { + const uri = this._currentPullRequestUri; + if (!uri) { + return; + } + this._openerService.open(uri, { openExternal: true }).catch(onUnexpectedError); + } + private _setVisible(visible: boolean): void { const wasVisible = this._visible; this._visible = visible; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts new file mode 100644 index 0000000000000..8efd0a9565f11 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; +import { Event } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IObservable, constObservable } from '../../../../../base/common/observable.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubInfo, ISession, ISessionCapabilities, ISessionFileChange, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; +// eslint-disable-next-line local/code-import-patterns +import { SessionHeader } from '../../../../../sessions/browser/parts/sessionHeader.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +interface IMockWorkspaceOptions { + label: string; + /** Whether the session runs in a worktree (folder icon vs. worktree icon). */ + isWorktree?: boolean; + isVirtualWorkspace?: boolean; + /** Pull request associated with the session's repository, if any. */ + pullRequest?: IGitHubInfo['pullRequest']; +} + +function createMockWorkspace(options: IMockWorkspaceOptions): ISessionWorkspace { + const root = URI.file(`/home/user/projects/${options.label}`); + const gitHubInfo: IGitHubInfo | undefined = options.pullRequest + ? { owner: 'microsoft', repo: options.label, pullRequest: options.pullRequest } + : undefined; + + const gitRepository: ISessionGitRepository = { + uri: root, + workTreeUri: options.isWorktree ? URI.file(`/home/user/.worktrees/${options.label}`) : undefined, + branchName: 'feature/session-header', + baseBranchName: 'main', + hasGitHubRemote: true, + gitHubInfo: constObservable(gitHubInfo), + }; + + const folder: ISessionFolder = { + root, + workingDirectory: gitRepository.workTreeUri ?? root, + name: options.label, + description: undefined, + gitRepository, + }; + + return { + uri: root, + label: options.label, + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: options.isVirtualWorkspace ?? false, + }; +} + +interface IMockSessionOptions { + title: string; + status?: SessionStatus; + isArchived?: boolean; + supportsRename?: boolean; + workspace?: ISessionWorkspace; + changes?: readonly ISessionFileChange[]; +} + +function createMockSession(options: IMockSessionOptions): IActiveSession { + const capabilities: ISessionCapabilities = { + supportsMultipleChats: false, + supportsRename: options.supportsRename ?? true, + }; + + return new class extends mock() { + override readonly sessionId = `local:${options.title}`; + override readonly resource = URI.parse(`vscode-session://session/${Math.random().toString(36).slice(2)}`); + override readonly capabilities = capabilities; + override readonly title: IObservable = constObservable(options.title); + override readonly status: IObservable = constObservable(options.status ?? SessionStatus.Completed); + override readonly isArchived: IObservable = constObservable(options.isArchived ?? false); + override readonly workspace: IObservable = constObservable(options.workspace); + override readonly changes: IObservable = constObservable(options.changes ?? []); + override readonly isCreated: IObservable = constObservable(true); + }(); +} + +function createMockListModelService(): ISessionsListModelService { + return new class extends mock() { + override readonly onDidChange = Event.None; + override isSessionRead(_session: ISession): boolean { return true; } + override getStatusIcon(status: SessionStatus, _isRead: boolean, isArchived: boolean, pullRequestIcon?: ThemeIcon): ThemeIcon { + switch (status) { + case SessionStatus.InProgress: + return { ...Codicon.sessionInProgress, color: themeColorFromId('textLink.foreground') }; + case SessionStatus.NeedsInput: + return { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') }; + case SessionStatus.Error: + return { ...Codicon.error, color: themeColorFromId('errorForeground') }; + default: + if (isArchived) { + return { ...Codicon.passFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; + } + if (pullRequestIcon) { + return pullRequestIcon; + } + return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; + } + } + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderHeader(ctx: ComponentFixtureContext, session: IActiveSession): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.defineInstance(ISessionsListModelService, createMockListModelService()); + reg.defineInstance(ISessionsManagementService, new class extends mock() { + override readonly onDidChangeSessions = Event.None; + override async renameSession() { } + }()); + reg.defineInstance(ISessionsService, new class extends mock() { + override setActive() { } + }()); + }, + }); + + // The session header reads `--session-view-background/foreground` (set by the + // hosting SessionView in production) for its surface colors, so mirror those + // here against the agents-window panel background. + container.style.width = '420px'; + container.style.setProperty('--session-view-background', 'var(--vscode-agentsPanel-background, var(--vscode-sideBar-background))'); + container.style.setProperty('--session-view-foreground', 'var(--vscode-agentsPanel-foreground, var(--vscode-sideBar-foreground))'); + container.style.backgroundColor = 'var(--session-view-background)'; + + const header = disposableStore.add(instantiationService.createInstance(SessionHeader)); + header.setSession(session); + container.appendChild(header.element); +} + +const openPr: IGitHubInfo['pullRequest'] = { + number: 12345, + uri: URI.parse('https://github.com/microsoft/vscode/pull/12345'), + icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }, +}; + +const draftPr: IGitHubInfo['pullRequest'] = { + number: 678, + uri: URI.parse('https://github.com/microsoft/vscode/pull/678'), + icon: { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }, +}; + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + SessionHeader_NoPullRequest: defineComponentFixture({ + render: (ctx) => renderHeader(ctx, createMockSession({ + title: 'Fix login bug', + workspace: createMockWorkspace({ label: 'vscode' }), + })), + }), + + SessionHeader_WithPullRequest: defineComponentFixture({ + render: (ctx) => renderHeader(ctx, createMockSession({ + title: 'Add session header PR link', + workspace: createMockWorkspace({ label: 'vscode', isWorktree: true, pullRequest: openPr }), + })), + }), + + SessionHeader_DraftPullRequest: defineComponentFixture({ + render: (ctx) => renderHeader(ctx, createMockSession({ + title: 'Refactor authentication flow', + workspace: createMockWorkspace({ label: 'vscode', isWorktree: true, pullRequest: draftPr }), + })), + }), + + SessionHeader_InProgress: defineComponentFixture({ + render: (ctx) => renderHeader(ctx, createMockSession({ + title: 'Investigate flaky test', + status: SessionStatus.InProgress, + workspace: createMockWorkspace({ label: 'vscode', isWorktree: true, pullRequest: openPr }), + })), + }), + + SessionHeader_NeedsInput: defineComponentFixture({ + render: (ctx) => renderHeader(ctx, createMockSession({ + title: 'Update documentation', + status: SessionStatus.NeedsInput, + workspace: createMockWorkspace({ label: 'vscode' }), + })), + }), + + SessionHeader_LongTitle: defineComponentFixture({ + render: (ctx) => renderHeader(ctx, createMockSession({ + title: 'Investigate and fix the flaky integration test in the notebook editor viewport rendering pipeline', + workspace: createMockWorkspace({ label: 'microsoft/vscode', isWorktree: true, pullRequest: openPr }), + })), + }), + + SessionHeader_NoWorkspace: defineComponentFixture({ + render: (ctx) => renderHeader(ctx, createMockSession({ + title: 'New Session', + })), + }), +}); From 2858f2c0200dc2f4f87aca248c69daaf85f3ce69 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 23 Jun 2026 12:46:13 -0400 Subject: [PATCH 037/696] add command to run to setting description (#322569) add command to run --- .../contrib/agentsVoice/browser/agentsVoice.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 8aa05c9f2267e..ecae669845382 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -501,7 +501,7 @@ configurationRegistry.registerConfiguration({ }, 'agents.voice.microphoneDevice': { type: 'string', - description: nls.localize('agents.voice.microphoneDevice', "The device ID of the microphone to use for voice mode. Use the 'Voice: Select Microphone' command to pick a device."), + markdownDescription: nls.localize('agents.voice.microphoneDevice.markdownDescription', "The device ID of the microphone to use for voice mode. Run the `Voice: Select Microphone` command to pick a device."), default: '', scope: ConfigurationScope.APPLICATION, ignoreSync: true, From 20b17526a353f91132a49f59e1db6db072843262 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:02:31 +0200 Subject: [PATCH 038/696] Add session grouping support to the sessions list (#322554) * Add session grouping support to the sessions list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix hygiene CSS indentation and address PR review feedback - Use single-line CSS comments to fix bad whitespace indentation - Keep all selected sessions (incl. pinned/Done) when creating a group - Remove unused SessionAddToGroupSubmenuId menu id - Clarify createGroup doc comment (name is required) - Report groupsChanged accurately when empty-group eviction deletes groups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/media/sessionsList.css | 32 + .../sessions/browser/views/sessionsList.ts | 685 ++++++++++++++++-- .../browser/views/sessionsViewActions.ts | 49 +- .../sessions/browser/sessionGroupsService.ts | 360 +++++++++ .../test/browser/sessionGroupsService.test.ts | 163 +++++ src/vs/sessions/sessions.common.main.ts | 1 + 6 files changed, 1244 insertions(+), 46 deletions(-) create mode 100644 src/vs/sessions/services/sessions/browser/sessionGroupsService.ts create mode 100644 src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 993ec5c93c27b..5d3a1cc777b4f 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -437,6 +437,38 @@ } } +/* Session groups: render like sections, with an inline name editor and a */ +/* whole-group highlight while a session is dragged into the group. */ +.session-group { + .session-group-input { + flex: 1 1 auto; + min-width: 0; + display: none; + + .monaco-inputbox { + width: 100%; + height: 20px; + line-height: 18px; + } + + .monaco-inputbox .input { + padding: 0 4px; + font-size: var(--vscode-agents-fontSize-label2, 11px); + } + } +} + +.session-group.session-group-editing .session-group-input { + display: block; +} + +/* While dragging a session into a group, the tree applies `.drop-target` to the */ +/* group header and every member row (subtree feedback). Highlight them as one */ +/* block without an insertion line. */ +.sessions-list-control .monaco-list-row.drop-target { + background-color: var(--vscode-list-dropBackground) !important; +} + @keyframes session-needs-input-pulse { 0%, diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index fc5f6845abc73..09208be68573b 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -8,7 +8,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { Gesture } from '../../../../../base/browser/touch.js'; import { IListVirtualDelegate, ListDragOverEffectPosition, ListDragOverEffectType, NotSelectableGroupId } from '../../../../../base/browser/ui/list/list.js'; import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js'; -import { IObjectTreeElement, ITreeNode, ITreeRenderer, ITreeContextMenuEvent, ObjectTreeElementCollapseState, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; +import { IObjectTreeElement, ITreeNode, ITreeRenderer, ITreeContextMenuEvent, ObjectTreeElementCollapseState, ITreeDragAndDrop, ITreeDragOverReaction, TreeDragOverBubble } from '../../../../../base/browser/ui/tree/tree.js'; import { RenderIndentGuides, TreeFindMode } from '../../../../../base/browser/ui/tree/abstractTree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; @@ -20,6 +20,7 @@ import { IObservable, IReader, autorun, observableSignalFromEvent, observableVal import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { fromNow } from '../../../../../base/common/date.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; import { MenuId, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; @@ -32,19 +33,21 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; -import { IStyleOverride, defaultButtonStyles, defaultFindWidgetStyles, defaultToggleStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IStyleOverride, defaultButtonStyles, defaultFindWidgetStyles, defaultInputBoxStyles, defaultToggleStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; -import { Action, ActionRunner, IAction, Separator } from '../../../../../base/common/actions.js'; +import { Action, ActionRunner, IAction, Separator, SubmenuAction } from '../../../../../base/common/actions.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { ISessionsManagementService, IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsListModelService, SessionSortMode } from '../../../../services/sessions/browser/sessionsListModelService.js'; +import { ISessionGroup, ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; +import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; // ============================================================================= // TEMPORARY (tracked by https://github.com/microsoft/vscode/issues/320480) @@ -77,8 +80,11 @@ const SESSION_SECTION_FOCUS_FROM_POINTER_CLASS = 'session-section-focus-from-poi export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); export const SessionItemContextMenuId = MenuId.SessionItemContextMenu; export const SessionSectionToolbarMenuId = new MenuId('SessionSectionToolbar'); +export const SessionGroupToolbarMenuId = new MenuId('SessionGroupToolbar'); export const IsSessionPinnedContext = new RawContextKey('sessionItem.isPinned', false); export const SessionItemHasBranchNameContext = new RawContextKey('sessionItem.hasBranchName', false); +/** Whether the focused session item currently belongs to a user group. */ +export const SessionItemInGroupContext = new RawContextKey('sessionItem.inGroup', false); export const SessionSectionTypeContext = new RawContextKey('sessionSection.type', ''); //#region Types @@ -106,6 +112,17 @@ export interface ISessionSection { readonly sessions: ISession[]; } +/** + * A user-created group rendered as a section-like header. Carries the backing + * {@link ISessionGroup} plus its currently-visible member sessions and whether + * the header should render its inline name editor. + */ +export interface ISessionGroupItem { + readonly group: ISessionGroup; + readonly sessions: ISession[]; + readonly editing: boolean; +} + export interface ISessionShowMore { readonly showMore: true; readonly kind: 'sessions' | 'folders'; @@ -114,16 +131,24 @@ export interface ISessionShowMore { readonly remainingCount: number; } -export type SessionListItem = ISession | ISessionSection | ISessionShowMore; +export type SessionListItem = ISession | ISessionSection | ISessionGroupItem | ISessionShowMore; + +function isSessionGroupItem(item: SessionListItem): item is ISessionGroupItem { + return 'group' in item; +} function isSessionSection(item: SessionListItem): item is ISessionSection { - return 'sessions' in item && Array.isArray((item as ISessionSection).sessions); + return !isSessionGroupItem(item) && 'sessions' in item && Array.isArray((item as ISessionSection).sessions); } function isSessionShowMore(item: SessionListItem): item is ISessionShowMore { return 'showMore' in item && (item as ISessionShowMore).showMore === true; } +function isSessionItem(item: SessionListItem): item is ISession { + return !isSessionGroupItem(item) && !isSessionSection(item) && !isSessionShowMore(item); +} + const SHOW_MORE_FOLDERS_LABEL = '__more_folders__'; const FOUR_DAYS_MS = 4 * 24 * 60 * 60 * 1000; @@ -150,7 +175,7 @@ class SessionsTreeDelegate implements IListVirtualDelegate { ) { } getHeight(element: SessionListItem): number { - if (isSessionSection(element)) { + if (isSessionSection(element) || isSessionGroupItem(element)) { return SessionsTreeDelegate.SECTION_HEIGHT; } if (isSessionShowMore(element)) { @@ -168,10 +193,13 @@ class SessionsTreeDelegate implements IListVirtualDelegate { } hasDynamicHeight(element: SessionListItem): boolean { - return !!this._approvalModel && !isSessionSection(element) && !isSessionShowMore(element); + return !!this._approvalModel && isSessionItem(element); } getTemplateId(element: SessionListItem): string { + if (isSessionGroupItem(element)) { + return SessionGroupRenderer.TEMPLATE_ID; + } if (isSessionSection(element)) { return SessionSectionRenderer.TEMPLATE_ID; } @@ -292,7 +320,7 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { const element = node.element; - if (isSessionSection(element) || isSessionShowMore(element)) { + if (!isSessionItem(element)) { return; } this.renderSession(element, template, createMatches(node.filterData)); @@ -699,6 +727,156 @@ class SessionSectionRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'session-group'; + readonly templateId = SessionGroupRenderer.TEMPLATE_ID; + + private readonly templatesByElement = new WeakMap(); + + constructor( + private readonly delegate: ISessionGroupRendererDelegate, + private readonly instantiationService: IInstantiationService, + private readonly contextKeyService: IContextKeyService, + ) { } + + renderTemplate(container: HTMLElement): ISessionGroupTemplate { + const disposables = new DisposableStore(); + + container.classList.add('session-section', 'session-group'); + const label = DOM.append(container, $('span.session-section-label')); + const inputContainer = DOM.append(container, $('.session-group-input')); + const count = DOM.append(container, $('span.session-section-count')); + const toolbarContainer = DOM.append(container, $('.session-section-toolbar')); + const chevron = DOM.append(container, $('span.session-section-chevron')); + chevron.setAttribute('aria-hidden', 'true'); + + const contextKeyService = disposables.add(this.contextKeyService.createScoped(container)); + const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + const toolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, SessionGroupToolbarMenuId, { + menuOptions: { shouldForwardArgs: true }, + })); + + return { container, label, inputContainer, count, toolbar, chevron, contextKeyService, disposables, elementDisposables: disposables.add(new DisposableStore()) }; + } + + renderElement(node: ITreeNode, _index: number, template: ISessionGroupTemplate): void { + const element = node.element; + if (!isSessionGroupItem(element)) { + return; + } + template.elementDisposables.clear(); + this.templatesByElement.set(element, template); + + template.label.textContent = element.group.name; + template.count.textContent = String(element.sessions.length); + this.updateChevron(template, node.collapsible, node.collapsed); + template.toolbar.context = element; + + template.container.classList.toggle('session-group-editing', element.editing); + if (element.editing) { + this.renderInput(element, template); + } else { + template.inputContainer.style.display = 'none'; + template.label.style.display = ''; + } + } + + private renderInput(element: ISessionGroupItem, template: ISessionGroupTemplate): void { + template.label.style.display = 'none'; + template.inputContainer.style.display = ''; + DOM.clearNode(template.inputContainer); + + const input = template.elementDisposables.add(new InputBox(template.inputContainer, undefined, { + inputBoxStyles: defaultInputBoxStyles, + ariaLabel: localize('sessionGroupName', "Group name"), + })); + input.value = element.group.name; + input.focus(); + input.select(); + + let done = false; + const commit = () => { + if (done) { + return; + } + done = true; + this.delegate.commitEdit(element.group, input.value.trim()); + }; + const cancel = () => { + if (done) { + return; + } + done = true; + this.delegate.cancelEdit(element.group); + }; + + template.elementDisposables.add(DOM.addStandardDisposableListener(input.inputElement, DOM.EventType.KEY_DOWN, e => { + if (e.equals(KeyCode.Enter)) { + e.preventDefault(); + e.stopPropagation(); + commit(); + } else if (e.equals(KeyCode.Escape)) { + e.preventDefault(); + e.stopPropagation(); + cancel(); + } + })); + template.elementDisposables.add(DOM.addDisposableListener(input.inputElement, DOM.EventType.BLUR, () => commit())); + } + + /** Forwarded from the owning list when the group's collapse state toggles. */ + updateCollapseState(element: ISessionGroupItem, collapsed: boolean): void { + const template = this.templatesByElement.get(element); + if (template) { + this.updateChevron(template, true, collapsed); + } + } + + private updateChevron(template: ISessionGroupTemplate, collapsible: boolean, collapsed: boolean): void { + template.chevron.className = 'session-section-chevron'; + if (collapsible) { + template.chevron.classList.add('collapsible'); + const icon = collapsed ? Codicon.chevronRight : Codicon.chevronDown; + template.chevron.classList.add(...ThemeIcon.asClassNameArray(icon)); + } + } + + disposeElement(node: ITreeNode, _index: number, template: ISessionGroupTemplate): void { + if (isSessionGroupItem(node.element)) { + this.templatesByElement.delete(node.element); + } + template.elementDisposables.clear(); + } + + disposeTemplate(template: ISessionGroupTemplate): void { + template.disposables.dispose(); + } +} + +//#endregion + //#region Show More Renderer class SessionShowMoreRenderer implements ITreeRenderer { @@ -741,6 +919,9 @@ class SessionsAccessibilityProvider { } getAriaLabel(element: SessionListItem): string | null { + if (isSessionGroupItem(element)) { + return `${element.group.name}, ${element.sessions.length}`; + } if (isSessionSection(element)) { return `${element.label}, ${element.sessions.length}`; } @@ -778,6 +959,12 @@ interface ISessionsListDndDelegate { canDropOn(dragged: ISession[], target: ISession): boolean; /** Apply the reorder, placing the dragged sessions before/after the target. */ reorder(dragged: ISession[], target: ISession, position: 'before' | 'after'): void; + /** The id of the group the session belongs to, or `undefined`. */ + getGroupIdOfSession(session: ISession): string | undefined; + /** Add the given sessions to the group. */ + addSessionsToGroup(sessions: ISession[], groupId: string): void; + /** Reorder the dragged group before/after the target group. */ + reorderGroup(draggedGroupId: string, targetGroupId: string, position: 'before' | 'after'): void; } class SessionsListDragAndDrop extends Disposable implements ITreeDragAndDrop { @@ -789,6 +976,9 @@ class SessionsListDragAndDrop extends Disposable implements ITreeDragAndDrop this.delegate.getGroupIdOfSession(s) === groupId)) { + return undefined; + } + return groupId; + } + /** * Resolve the session the drop should be positioned against, or `undefined` * if the current drag is not a valid in-list reorder. */ private resolveReorderTarget(data: IDragAndDropData, targetElement: SessionListItem | undefined): ISession | undefined { - if (!targetElement || isSessionSection(targetElement) || isSessionShowMore(targetElement)) { + if (!targetElement || !isSessionItem(targetElement)) { return undefined; } const target = targetElement; @@ -876,8 +1149,16 @@ class SessionsListDragAndDrop extends Disposable implements ITreeDragAndDrop !isSessionSection(e) && !isSessionShowMore(e)); + return elements.filter(isSessionItem); } } @@ -938,6 +1219,10 @@ export interface ISessionsList { isWorkspaceGroupCapped(): boolean; setOpenWindowSourceFolder(folder: URI | undefined): void; collapseAllSections(): void; + createGroupFromSessions(sessions: ISession[]): void; + beginRenameGroup(groupId: string): void; + addSessionsToGroup(sessions: ISession[], groupId: string): void; + getGroupsInDisplayOrder(): ISessionGroup[]; } export class SessionsList extends Disposable implements ISessionsList { @@ -980,6 +1265,10 @@ export class SessionsList extends Disposable implements ISessionsList { private hasFindPattern = false; private suspendCollapseStatePersistence = false; + /** The group whose header is currently showing its inline name editor. */ + private _editingGroupId: string | undefined; + private _groupRenderer!: SessionGroupRenderer; + private readonly _onDidUpdate = this._register(new Emitter()); readonly onDidUpdate: Event = this._onDidUpdate.event; @@ -994,6 +1283,7 @@ export class SessionsList extends Disposable implements ISessionsList { @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, + @ISessionGroupsService private readonly _sessionGroupsService: ISessionGroupsService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @@ -1044,6 +1334,11 @@ export class SessionsList extends Disposable implements ISessionsList { const showMoreRenderer = new SessionShowMoreRenderer(); const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, instantiationService, contextKeyService); + const groupRenderer = new SessionGroupRenderer({ + commitEdit: (group, name) => this.commitGroupEdit(group, name), + cancelEdit: group => this.cancelGroupEdit(group), + }, instantiationService, contextKeyService); + this._groupRenderer = groupRenderer; // Read (don't bind) `IsPhoneLayoutContext` from the parent context so we // observe the workbench's value rather than shadowing it with a fresh @@ -1059,6 +1354,7 @@ export class SessionsList extends Disposable implements ISessionsList { [ sessionRenderer, sectionRenderer, + groupRenderer, showMoreRenderer, ], { @@ -1067,9 +1363,15 @@ export class SessionsList extends Disposable implements ISessionsList { isReorderable: session => this.isReorderable(session), canDropOn: (dragged, target) => this.canReorderOnto(dragged, target), reorder: (dragged, target, position) => this.reorderSessions(dragged, target, position), + getGroupIdOfSession: session => this._sessionGroupsService.getGroupOfSession(session.sessionId), + addSessionsToGroup: (sessions, groupId) => this.addSessionsToGroup(sessions, groupId), + reorderGroup: (draggedGroupId, targetGroupId, position) => this.reorderGroup(draggedGroupId, targetGroupId, position), })), identityProvider: { getId: (element: SessionListItem) => { + if (isSessionGroupItem(element)) { + return `group:${element.group.id}`; + } if (isSessionSection(element)) { return `section:${element.id}`; } @@ -1079,6 +1381,9 @@ export class SessionsList extends Disposable implements ISessionsList { return element.resource.toString(); }, getGroupId: (element: SessionListItem) => { + if (isSessionGroupItem(element)) { + return NotSelectableGroupId; + } if (isSessionSection(element)) { return NotSelectableGroupId; } @@ -1105,6 +1410,9 @@ export class SessionsList extends Disposable implements ISessionsList { }, keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (element: SessionListItem) => { + if (isSessionGroupItem(element)) { + return element.group.name; + } if (isSessionSection(element)) { return element.label; } @@ -1138,7 +1446,7 @@ export class SessionsList extends Disposable implements ISessionsList { this.update(); return; } - if (!isSessionSection(element)) { + if (!isSessionSection(element) && !isSessionGroupItem(element)) { this.markRead(element); this.options.onSessionOpen(element.resource, e.editorOptions.preserveFocus ?? false, e.sideBySide); } @@ -1173,7 +1481,12 @@ export class SessionsList extends Disposable implements ISessionsList { this._register(this.tree.onDidChangeCollapseState(e => { const element = e.node.element; - if (element && isSessionSection(element)) { + if (element && isSessionGroupItem(element)) { + this._groupRenderer.updateCollapseState(element, e.node.collapsed); + if (!this.suspendCollapseStatePersistence) { + this.saveSectionCollapseState(`group:${element.group.id}`, e.node.collapsed); + } + } else if (element && isSessionSection(element)) { sectionRenderer.updateCollapseState(element, e.node.collapsed); if (!this.suspendCollapseStatePersistence) { this.saveSectionCollapseState(element.id, e.node.collapsed); @@ -1218,6 +1531,12 @@ export class SessionsList extends Disposable implements ISessionsList { } })); + this._register(this._sessionGroupsService.onDidChange(() => { + if (this.visible) { + this.update(); + } + })); + this._register(this._agentHostFilterService.onDidChange(() => { if (this.visible) { this.update(); @@ -1301,7 +1620,55 @@ export class SessionsList extends Disposable implements ISessionsList { } const grouping = this.options.grouping(); - const sections = groupSessionsForList(filtered, grouping, this.options.sorting(), session => this.isSessionPinned(session), (s, sorting) => this._sessionsListModelService.getSortKey(s, sortingToMode(sorting))); + const sorting = this.options.sorting(); + const mode = sortingToMode(sorting); + const sortKeyOf = (s: ISession) => this._sessionsListModelService.getSortKey(s, mode); + const sortKeyForGrouping = (s: ISession, srt: SessionsSorting) => this._sessionsListModelService.getSortKey(s, sortingToMode(srt)); + + // Pull regular (non-pinned, non-archived) grouped sessions out of the + // normal date/workspace sectioning so they render under their group. + // Pinned and archived sessions keep their precedence and stay in their + // sections even when they belong to a group (their membership is + // retained so they return to the group once unpinned/restored). + const groupedMembers = new Map(); + const groupedRegularIds = new Set(); + for (const s of filtered) { + if (s.isArchived.get() || this.isSessionPinned(s)) { + continue; + } + const groupId = this._sessionGroupsService.getGroupOfSession(s.sessionId); + if (groupId !== undefined && this._sessionGroupsService.getGroup(groupId)) { + let members = groupedMembers.get(groupId); + if (!members) { + members = []; + groupedMembers.set(groupId, members); + } + members.push(s); + groupedRegularIds.add(s.sessionId); + } + } + // Keep a group being renamed visible even if it currently has no visible + // members, so its inline name editor stays on screen. + if (this._editingGroupId && this._sessionGroupsService.getGroup(this._editingGroupId) && !groupedMembers.has(this._editingGroupId)) { + groupedMembers.set(this._editingGroupId, []); + } + + const forSections = groupedRegularIds.size > 0 ? filtered.filter(s => !groupedRegularIds.has(s.sessionId)) : filtered; + + // Build the group blocks with members sorted by the normal sort logic and + // a representative key (manual override, else the best member key) used to + // place the group among the other top-level items. + const groupItems: { item: ISessionGroupItem; repKey: number }[] = []; + for (const [groupId, members] of groupedMembers) { + const group = this._sessionGroupsService.getGroup(groupId)!; + const sortedMembers = sortSessions(members, sorting, sortKeyForGrouping); + const naturalRep = sortedMembers.length > 0 ? Math.max(...sortedMembers.map(sortKeyOf)) : group.createdAt; + const repKey = group.sortKeyOverride ?? naturalRep; + groupItems.push({ item: { group, sessions: sortedMembers, editing: group.id === this._editingGroupId }, repKey }); + } + groupItems.sort((a, b) => b.repKey - a.repKey); + + const sections = groupSessionsForList(forSections, grouping, sorting, session => this.isSessionPinned(session), (s, srt) => this._sessionsListModelService.getSortKey(s, sortingToMode(srt))); const hasTodaySessions = sections.some(s => s.id === 'today' && s.sessions.length > 0); @@ -1403,37 +1770,89 @@ export class SessionsList extends Disposable implements ISessionsList { }; }; - const moreFolderSections: ISessionSection[] = []; - // The archived ("Done") section should always appear after the - // "more workspaces" toggle (both collapsed and expanded states) - // so it remains the very last group in the list. - let archivedSection: ISessionSection | undefined; - for (const section of sections) { - if (moreFolderSectionIds.has(section.id)) { - moreFolderSections.push(section); - } else if (partitionFolders && section.id === 'archived') { - archivedSection = section; - } else { - children.push(renderSection(section)); - } + const renderGroup = (groupItem: ISessionGroupItem): IObjectTreeElement => { + return { + element: groupItem, + collapsible: true, + collapsed: this.getSavedCollapseState(`group:${groupItem.group.id}`) ?? ObjectTreeElementCollapseState.PreserveOrExpanded, + children: groupItem.sessions.map(session => ({ element: session as SessionListItem })), + }; + }; + + const sectionRepKey = (section: ISessionSection): number => + section.sessions.length > 0 ? Math.max(...section.sessions.map(sortKeyOf)) : Number.NEGATIVE_INFINITY; + + const pinnedSection = sections.find(s => s.id === 'pinned'); + if (pinnedSection) { + children.push(renderSection(pinnedSection)); } - if (moreFolderSections.length > 0) { - if (this.expandedMoreFolders) { - for (const section of moreFolderSections) { + if (grouping === SessionsGrouping.Date) { + // Interleave groups among the date sections by representative key so a + // group sorts to wherever its most-recent member would place it. + // Pinned stays at the top, Done (archived) stays at the bottom. + type Block = { key: number; render: () => IObjectTreeElement }; + const blocks: Block[] = []; + for (const section of sections) { + if (section.id === 'pinned' || section.id === 'archived') { + continue; + } + blocks.push({ key: sectionRepKey(section), render: () => renderSection(section) }); + } + for (const groupItem of groupItems) { + blocks.push({ key: groupItem.repKey, render: () => renderGroup(groupItem.item) }); + } + blocks.sort((a, b) => b.key - a.key); + for (const block of blocks) { + children.push(block.render()); + } + const archived = sections.find(s => s.id === 'archived'); + if (archived) { + children.push(renderSection(archived)); + } + } else { + // Workspace grouping: groups render as a block right after Pinned and + // before the (alphabetical) workspace sections, sorted among + // themselves by representative key. + for (const groupItem of groupItems) { + children.push(renderGroup(groupItem.item)); + } + + const moreFolderSections: ISessionSection[] = []; + // The archived ("Done") section should always appear after the + // "more workspaces" toggle (both collapsed and expanded states) + // so it remains the very last group in the list. + let archivedSection: ISessionSection | undefined; + for (const section of sections) { + if (section.id === 'pinned') { + continue; + } + if (moreFolderSectionIds.has(section.id)) { + moreFolderSections.push(section); + } else if (partitionFolders && section.id === 'archived') { + archivedSection = section; + } else { children.push(renderSection(section)); } - children.push({ - element: { showMore: true as const, kind: 'folders' as const, mode: 'less' as const, sectionLabel: SHOW_MORE_FOLDERS_LABEL, remainingCount: 0 }, - }); - } else { - children.push({ - element: { showMore: true as const, kind: 'folders' as const, mode: 'more' as const, sectionLabel: SHOW_MORE_FOLDERS_LABEL, remainingCount: moreFolderSections.length }, - }); } - } - if (archivedSection) { - children.push(renderSection(archivedSection)); + + if (moreFolderSections.length > 0) { + if (this.expandedMoreFolders) { + for (const section of moreFolderSections) { + children.push(renderSection(section)); + } + children.push({ + element: { showMore: true as const, kind: 'folders' as const, mode: 'less' as const, sectionLabel: SHOW_MORE_FOLDERS_LABEL, remainingCount: 0 }, + }); + } else { + children.push({ + element: { showMore: true as const, kind: 'folders' as const, mode: 'more' as const, sectionLabel: SHOW_MORE_FOLDERS_LABEL, remainingCount: moreFolderSections.length }, + }); + } + } + if (archivedSection) { + children.push(renderSection(archivedSection)); + } } this.tree.setChildren(null, children); @@ -1545,10 +1964,14 @@ export class SessionsList extends Disposable implements ISessionsList { /** * Whether the dragged sessions can be reordered relative to the target. - * When grouping by workspace, reordering is restricted to within the same - * workspace group. + * Reordering stays within the same scope: dragged sessions must share the + * target's group membership, and (when grouping by workspace) its workspace. */ private canReorderOnto(dragged: ISession[], target: ISession): boolean { + const targetGroup = this._sessionGroupsService.getGroupOfSession(target.sessionId); + if (dragged.some(s => this._sessionGroupsService.getGroupOfSession(s.sessionId) !== targetGroup)) { + return false; + } if (this.options.grouping() === SessionsGrouping.Workspace) { const targetLabel = sessionWorkspaceLabel(target); return dragged.every(s => sessionWorkspaceLabel(s) === targetLabel); @@ -1571,9 +1994,12 @@ export class SessionsList extends Disposable implements ISessionsList { // Derive neighbours from the actual visible display order (which already // respects filtering and grouping) so the drop slot matches what the user // sees. For workspace grouping only the target's workspace participates; - // for date grouping the regular list is one continuous sequence. + // for date grouping the regular list is one continuous sequence. When the + // target belongs to a group, reordering stays within that group's members. + const targetGroup = this._sessionGroupsService.getGroupOfSession(target.sessionId); let scope = this.getVisibleSessions().filter(s => this.isReorderable(s)); - if (grouping === SessionsGrouping.Workspace) { + scope = scope.filter(s => this._sessionGroupsService.getGroupOfSession(s.sessionId) === targetGroup); + if (targetGroup === undefined && grouping === SessionsGrouping.Workspace) { const targetLabel = sessionWorkspaceLabel(target); scope = scope.filter(s => sessionWorkspaceLabel(s) === targetLabel); } @@ -1605,8 +2031,116 @@ export class SessionsList extends Disposable implements ISessionsList { this._sessionsListModelService.applySortChanges(mode, set, clear); } + // -- Groups -- + + /** + * Create a new group containing the given sessions and start renaming it. + * All selected sessions become members — pinned and archived (Done) sessions + * keep their membership and continue to render in their own sections, but + * return to the group once unpinned/restored. + */ + createGroupFromSessions(sessions: ISession[]): void { + const group = this._sessionGroupsService.createGroup(localize('newGroupName', "New Group"), sessions.map(s => s.sessionId)); + this._editingGroupId = group.id; + this.update(); + } + + /** Begin inline renaming of the group's header. */ + beginRenameGroup(groupId: string): void { + if (!this._sessionGroupsService.getGroup(groupId)) { + return; + } + this._editingGroupId = groupId; + this.update(); + } + + addSessionsToGroup(sessions: ISession[], groupId: string): void { + for (const session of sessions) { + this._sessionGroupsService.addToGroup(session.sessionId, groupId); + } + } + + private commitGroupEdit(group: ISessionGroup, name: string): void { + this._editingGroupId = undefined; + const trimmed = name.trim(); + if (trimmed) { + this._sessionGroupsService.renameGroup(group.id, trimmed); + } + this.update(); + } + + private cancelGroupEdit(_group: ISessionGroup): void { + this._editingGroupId = undefined; + this.update(); + } + + /** + * Reorder a group so it lands before/after the target group, persisting a + * synthetic sort key spread between the surrounding groups' representative + * keys. Reuses the session reorder math for a single-item block. + */ + private reorderGroup(draggedGroupId: string, targetGroupId: string, position: 'before' | 'after'): void { + const order = this.getGroupsInDisplayOrder(); + const repKey = (groupId: string): number => { + const group = this._sessionGroupsService.getGroup(groupId); + if (group?.sortKeyOverride !== undefined) { + return group.sortKeyOverride; + } + const mode = sortingToMode(this.options.sorting()); + const memberKeys = this._sessionGroupsService.getSessionIdsInGroup(groupId) + .map(id => this.sessions.find(s => s.sessionId === id)) + .filter((s): s is ISession => !!s) + .map(s => this._sessionsListModelService.getSortKey(s, mode)); + return memberKeys.length > 0 ? Math.max(...memberKeys) : (group?.createdAt ?? Date.now()); + }; + + const remaining = order.filter(g => g.id !== draggedGroupId); + const targetIndex = remaining.findIndex(g => g.id === targetGroupId); + if (targetIndex === -1) { + return; + } + const insertIndex = position === 'before' ? targetIndex : targetIndex + 1; + const above = remaining[insertIndex - 1]; + const below = remaining[insertIndex]; + + const { set, clear } = computeReorderSortChanges({ + draggedIds: [draggedGroupId], + naturalKeys: [repKey(draggedGroupId)], + aboveKey: above ? repKey(above.id) : undefined, + belowKey: below ? repKey(below.id) : undefined, + now: Date.now(), + fallbackStep: SORT_FALLBACK_STEP_MS, + }); + for (const id of clear) { + this._sessionGroupsService.setGroupSortKey(id, undefined); + } + for (const [id, value] of set) { + this._sessionGroupsService.setGroupSortKey(id, value); + } + } + + /** + * Groups in their current top-to-bottom display order, computed from member + * representative keys (and manual overrides). Used to keep the "Add to Group" + * menu and group reordering consistent with the list. + */ + getGroupsInDisplayOrder(): ISessionGroup[] { + const mode = sortingToMode(this.options.sorting()); + const repKey = (group: ISessionGroup): number => { + if (group.sortKeyOverride !== undefined) { + return group.sortKeyOverride; + } + const memberKeys = this._sessionGroupsService.getSessionIdsInGroup(group.id) + .map(id => this.sessions.find(s => s.sessionId === id)) + .filter((s): s is ISession => !!s) + .map(s => this._sessionsListModelService.getSortKey(s, mode)); + return memberKeys.length > 0 ? Math.max(...memberKeys) : group.createdAt; + }; + return this._sessionGroupsService.getGroups().sort((a, b) => repKey(b) - repKey(a)); + } + private getMultiSelectedSessions(session: ISession): ISession[] { - const selection = this.tree.getSelection().filter((s): s is ISession => !!s && !isSessionSection(s) && !isSessionShowMore(s)); + const selection = this.tree.getSelection().filter((s): s is ISession => !!s && isSessionItem(s)); return selection.includes(session) ? [session, ...selection.filter(s => s !== session)] : [session]; } @@ -1616,13 +2150,20 @@ export class SessionsList extends Disposable implements ISessionsList { return; } + if (isSessionGroupItem(element)) { + this.showGroupContextMenu(element, e.anchor); + return; + } + const selectedSessions = this.getMultiSelectedSessions(element); + const inGroup = this._sessionGroupsService.getGroupOfSession(element.sessionId) !== undefined; const contextOverlay: [string, boolean | string][] = [ [IsSessionPinnedContext.key, this.isSessionPinned(element)], [SessionIsArchivedContext.key, element.isArchived.get()], [SessionIsReadContext.key, this.isSessionRead(element)], [SessionItemHasBranchNameContext.key, !!element.workspace.get()?.folders[0]?.gitRepository?.branchName?.trim()], + [SessionItemInGroupContext.key, inGroup], [ChatSessionTypeContext.key, element.sessionType], [ChatSessionProviderIdContext.key, element.providerId], [ChatSessionSupportsRenameContext.key, element.capabilities.supportsRename ?? false], @@ -1648,7 +2189,11 @@ export class SessionsList extends Disposable implements ISessionsList { }; this.contextMenuService.showContextMenu({ - getActions: () => Separator.join(...menu.getActions({ arg: selectedSessions, shouldForwardArgs: true }).map(([, actions]) => actions.map(wrapForExtensions))), + getActions: () => { + const base = Separator.join(...menu.getActions({ arg: selectedSessions, shouldForwardArgs: true }).map(([, actions]) => actions.map(wrapForExtensions))); + const groupActions = this.getGroupSessionActions(selectedSessions); + return groupActions.length > 0 ? [...base, new Separator(), ...groupActions] : base; + }, getAnchor: () => e.anchor, getKeyBinding: (action) => this.keybindingService.lookupKeybinding(action.id) ?? undefined, }); @@ -1656,6 +2201,56 @@ export class SessionsList extends Disposable implements ISessionsList { menu.dispose(); } + /** + * Build the group-related context menu actions for the given session(s): + * "Create Group", an "Add to Group"/"Move to Group" submenu listing the + * groups in display order, and "Remove from Group" when applicable. + */ + private getGroupSessionActions(selected: ISession[]): IAction[] { + const actions: IAction[] = []; + + actions.push(new Action('sessions.createGroup', localize('createGroupAction', "Create Group"), undefined, true, async () => { + this.createGroupFromSessions(selected); + })); + + const currentGroupIds = new Set(selected.map(s => this._sessionGroupsService.getGroupOfSession(s.sessionId))); + const currentGroupId = currentGroupIds.size === 1 ? [...currentGroupIds][0] : undefined; + + const targetGroups = this.getGroupsInDisplayOrder().filter(g => g.id !== currentGroupId); + if (targetGroups.length > 0) { + const subActions = targetGroups.map(g => new Action(`sessions.addToGroup.${g.id}`, g.name, undefined, true, async () => { + this.addSessionsToGroup(selected, g.id); + })); + const label = currentGroupId !== undefined ? localize('moveToGroupAction', "Move to Group") : localize('addToGroupAction', "Add to Group"); + actions.push(new SubmenuAction('sessions.addToGroupSubmenu', label, subActions)); + } + + if (currentGroupId !== undefined) { + actions.push(new Action('sessions.removeFromGroup', localize('removeFromGroupAction', "Remove from Group"), undefined, true, async () => { + for (const session of selected) { + this._sessionGroupsService.removeFromGroup(session.sessionId); + } + })); + } + + return actions; + } + + private showGroupContextMenu(groupItem: ISessionGroupItem, anchor: ITreeContextMenuEvent['anchor']): void { + const actions: IAction[] = [ + new Action('sessions.renameGroupAction', localize('renameGroupAction', "Rename..."), undefined, true, async () => { + this.beginRenameGroup(groupItem.group.id); + }), + new Action('sessions.deleteGroupAction', localize('deleteGroupAction', "Delete Group"), undefined, true, async () => { + this._sessionGroupsService.deleteGroup(groupItem.group.id); + }), + ]; + this.contextMenuService.showContextMenu({ + getActions: () => actions, + getAnchor: () => anchor, + }); + } + resetSectionCollapseState(): void { this.storageService.remove(SessionsList.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index f44a58f95dc4c..4e2175fbee3cc 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -21,8 +21,9 @@ import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../../browser/work import { EditorsVisibleContext, EditorAreaFocusContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { SessionsCategories } from '../../../../common/categories.js'; import { ChatSessionSupportsDeleteContext, ChatSessionSupportsRenameContext, IsActiveSessionArchivedContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js'; -import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection } from './sessionsList.js'; +import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext, openSessionToTheSide } from './sessionsView.js'; import { Menus } from '../../../../browser/menus.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -540,6 +541,52 @@ registerAction2(class ArchiveSectionAction extends Action2 { } }); +// Group Header Actions + +registerAction2(class RenameGroupAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.renameGroup', + title: localize2('renameGroup', "Rename"), + icon: Codicon.edit, + menu: [{ + id: SessionGroupToolbarMenuId, + group: 'navigation', + order: 0, + }] + }); + } + run(accessor: ServicesAccessor, context?: ISessionGroupItem): void { + if (!context) { + return; + } + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.beginRenameGroup(context.group.id); + } +}); + +registerAction2(class DeleteGroupAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.deleteGroup', + title: localize2('deleteGroup', "Delete Group"), + icon: Codicon.trash, + menu: [{ + id: SessionGroupToolbarMenuId, + group: 'navigation', + order: 1, + }] + }); + } + run(accessor: ServicesAccessor, context?: ISessionGroupItem): void { + if (!context) { + return; + } + accessor.get(ISessionGroupsService).deleteGroup(context.group.id); + } +}); + // Session Item Actions registerAction2(class PinSessionAction extends Action2 { diff --git a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts new file mode 100644 index 0000000000000..38ee0d721d8a2 --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts @@ -0,0 +1,360 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { generateUuid } from '../../../../base/common/uuid.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ISessionsManagementService } from '../common/sessionsManagement.js'; + +/** + * A user-created group of sessions in the sessions list. Groups render like + * section headers and can be reordered, renamed and deleted. Membership of a + * session in a group is tracked separately (see {@link ISessionGroupsService}). + */ +export interface ISessionGroup { + /** Stable identifier (uuid). */ + readonly id: string; + /** User-provided display name. */ + readonly name: string; + /** + * Manual sort-key override applied when the user drags a group to reorder + * it. `undefined` means the group falls back to its natural placement (the + * best/highest sort key among its members). Expressed in the same numeric + * space as session sort keys (timestamps) so groups interleave with the + * date/workspace sections. Higher values sort higher in the list. + */ + readonly sortKeyOverride: number | undefined; + /** Creation timestamp (ms). Used to evict the oldest empty group. */ + readonly createdAt: number; +} + +export interface ISessionGroupsChangeEvent { + /** Groups added, removed, renamed or reordered. */ + readonly groupsChanged: boolean; + /** Session ids whose group membership changed. */ + readonly membershipChanged: ReadonlySet; +} + +/** + * Service that owns user-created session groups and the mapping of sessions to + * groups. State is purely local (persisted to profile storage) and not synced + * to providers. + * + * A session belongs to at most one group. Group membership is independent of + * where the session renders: a grouped session that becomes pinned or archived + * is rendered in the Pinned/Done section but retains its membership, so it + * returns to the group once unpinned/restored. + */ +export interface ISessionGroupsService { + readonly _serviceBrand: undefined; + + /** Fires when groups or membership change. */ + readonly onDidChange: Event; + + /** + * All groups in display order (including currently-empty ones). The list + * view omits groups with no visible members when rendering. + */ + getGroups(): ISessionGroup[]; + + /** Look up a group by id (including currently-empty groups). */ + getGroup(groupId: string): ISessionGroup | undefined; + + /** + * Create a new group with the given name. Returns the created group. When + * `memberSessionIds` are given they are added to the new group. + */ + createGroup(name: string, memberSessionIds?: Iterable): ISessionGroup; + + /** Rename an existing group. No-op if the group does not exist. */ + renameGroup(groupId: string, name: string): void; + + /** Delete a group and remove all of its members' membership. */ + deleteGroup(groupId: string): void; + + /** Add a session to a group (removing it from any previous group). */ + addToGroup(sessionId: string, groupId: string): void; + + /** Remove a session from its group, if any. */ + removeFromGroup(sessionId: string): void; + + /** The id of the group the session belongs to, or `undefined`. */ + getGroupOfSession(sessionId: string): string | undefined; + + /** The session ids that belong to the given group. */ + getSessionIdsInGroup(groupId: string): string[]; + + /** + * Persist a manual sort-key override for a group (or clear it with + * `undefined`). Used when the user drags a group to reorder it. + */ + setGroupSortKey(groupId: string, sortKeyOverride: number | undefined): void; +} + +export const ISessionGroupsService = createDecorator('sessionGroupsService'); + +interface ISerializedState { + readonly groups: readonly ISessionGroup[]; + /** sessionId -> groupId */ + readonly membership: Readonly>; +} + +export class SessionGroupsService extends Disposable implements ISessionGroupsService { + + declare readonly _serviceBrand: undefined; + + private static readonly STORAGE_KEY = 'sessionsListControl.groups'; + + /** + * Maximum number of empty groups (no members) retained in storage. When a + * new empty group would exceed this, the oldest empty group is evicted. + */ + private static readonly MAX_EMPTY_GROUPS = 3; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _groups = new Map(); + /** sessionId -> groupId */ + private readonly _membership = new Map(); + + constructor( + @IStorageService private readonly storageService: IStorageService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + this.load(); + + this._register(this.sessionsManagementService.onDidChangeSessions(e => { + if (e.removed.length === 0) { + return; + } + const changed = new Set(); + for (const session of e.removed) { + if (this._membership.delete(session.sessionId)) { + changed.add(session.sessionId); + } + } + if (changed.size > 0) { + const evicted = this.evictExcessEmptyGroups(); + this.save(); + this._onDidChange.fire({ groupsChanged: evicted, membershipChanged: changed }); + } + })); + } + + getGroups(): ISessionGroup[] { + return this.sortGroups([...this._groups.values()]); + } + + getGroup(groupId: string): ISessionGroup | undefined { + return this._groups.get(groupId); + } + + createGroup(name: string, memberSessionIds?: Iterable): ISessionGroup { + const group: ISessionGroup = { id: generateUuid(), name, sortKeyOverride: undefined, createdAt: Date.now() }; + this._groups.set(group.id, group); + + const membershipChanged = new Set(); + if (memberSessionIds) { + for (const sessionId of memberSessionIds) { + this.setMembership(sessionId, group.id, membershipChanged); + } + } + + this.evictExcessEmptyGroups(); + this.save(); + this._onDidChange.fire({ groupsChanged: true, membershipChanged }); + return group; + } + + renameGroup(groupId: string, name: string): void { + const group = this._groups.get(groupId); + if (!group || group.name === name) { + return; + } + this._groups.set(groupId, { ...group, name }); + this.save(); + this._onDidChange.fire({ groupsChanged: true, membershipChanged: new Set() }); + } + + deleteGroup(groupId: string): void { + if (!this._groups.delete(groupId)) { + return; + } + const membershipChanged = new Set(); + for (const [sessionId, gid] of this._membership) { + if (gid === groupId) { + this._membership.delete(sessionId); + membershipChanged.add(sessionId); + } + } + this.save(); + this._onDidChange.fire({ groupsChanged: true, membershipChanged }); + } + + addToGroup(sessionId: string, groupId: string): void { + if (!this._groups.has(groupId) || this._membership.get(sessionId) === groupId) { + return; + } + const membershipChanged = new Set(); + this.setMembership(sessionId, groupId, membershipChanged); + const evicted = this.evictExcessEmptyGroups(); + this.save(); + this._onDidChange.fire({ groupsChanged: evicted, membershipChanged }); + } + + removeFromGroup(sessionId: string): void { + if (!this._membership.delete(sessionId)) { + return; + } + const evicted = this.evictExcessEmptyGroups(); + this.save(); + this._onDidChange.fire({ groupsChanged: evicted, membershipChanged: new Set([sessionId]) }); + } + + getGroupOfSession(sessionId: string): string | undefined { + return this._membership.get(sessionId); + } + + getSessionIdsInGroup(groupId: string): string[] { + const result: string[] = []; + for (const [sessionId, gid] of this._membership) { + if (gid === groupId) { + result.push(sessionId); + } + } + return result; + } + + setGroupSortKey(groupId: string, sortKeyOverride: number | undefined): void { + const group = this._groups.get(groupId); + if (!group || group.sortKeyOverride === sortKeyOverride) { + return; + } + this._groups.set(groupId, { ...group, sortKeyOverride }); + this.save(); + this._onDidChange.fire({ groupsChanged: true, membershipChanged: new Set() }); + } + + // -- Helpers -- + + private setMembership(sessionId: string, groupId: string, changed: Set): void { + if (this._membership.get(sessionId) !== groupId) { + this._membership.set(sessionId, groupId); + changed.add(sessionId); + } + } + + private hasMembers(groupId: string): boolean { + for (const gid of this._membership.values()) { + if (gid === groupId) { + return true; + } + } + return false; + } + + /** + * Keep at most {@link MAX_EMPTY_GROUPS} groups with no members, evicting the + * oldest empty groups (by `createdAt`) beyond that cap. Returns whether any + * group was deleted. + */ + private evictExcessEmptyGroups(): boolean { + const empty = [...this._groups.values()] + .filter(group => !this.hasMembers(group.id)) + .sort((a, b) => a.createdAt - b.createdAt); + let deleted = false; + for (let i = 0; i < empty.length - SessionGroupsService.MAX_EMPTY_GROUPS; i++) { + this._groups.delete(empty[i].id); + deleted = true; + } + return deleted; + } + + /** + * Sort groups for display as a stable baseline: groups with a manual + * `sortKeyOverride` first (highest key first), then the rest by creation + * time (newest first). The list view computes the final interleaved + * placement using member sort keys; this baseline is used where member keys + * are not available (e.g. the "Add to Group" menu fallback). + */ + private sortGroups(groups: ISessionGroup[]): ISessionGroup[] { + return groups.sort((a, b) => { + const aHas = a.sortKeyOverride !== undefined; + const bHas = b.sortKeyOverride !== undefined; + if (aHas && bHas) { + return b.sortKeyOverride! - a.sortKeyOverride!; + } + if (aHas !== bHas) { + return aHas ? -1 : 1; + } + return b.createdAt - a.createdAt; + }); + } + + // -- Storage -- + + private load(): void { + const raw = this.storageService.get(SessionGroupsService.STORAGE_KEY, StorageScope.PROFILE); + if (!raw) { + return; + } + try { + const parsed = JSON.parse(raw) as Partial; + if (Array.isArray(parsed.groups)) { + for (const group of parsed.groups) { + if (group && typeof group.id === 'string' && typeof group.name === 'string') { + this._groups.set(group.id, { + id: group.id, + name: group.name, + sortKeyOverride: typeof group.sortKeyOverride === 'number' ? group.sortKeyOverride : undefined, + createdAt: typeof group.createdAt === 'number' ? group.createdAt : Date.now(), + }); + } + } + } + if (parsed.membership && typeof parsed.membership === 'object') { + for (const [sessionId, groupId] of Object.entries(parsed.membership)) { + if (typeof groupId === 'string' && this._groups.has(groupId)) { + this._membership.set(sessionId, groupId); + } + } + } + } catch { + // ignore corrupt data + } + } + + private save(): void { + if (this._groups.size === 0) { + this.storageService.remove(SessionGroupsService.STORAGE_KEY, StorageScope.PROFILE); + return; + } + const state: ISerializedState = { + groups: [...this._groups.values()], + membership: Object.fromEntries(this._membership), + }; + this.storageService.store(SessionGroupsService.STORAGE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); + } +} + +/** + * Best (highest) sort key among a group's currently-visible members, used to + * place the group among the other top-level list items. Returns `undefined` + * when the group has no visible members. + */ +export function groupSortKey(memberKeys: readonly number[]): number | undefined { + if (memberKeys.length === 0) { + return undefined; + } + return Math.max(...memberKeys); +} + +registerSingleton(ISessionGroupsService, SessionGroupsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts new file mode 100644 index 0000000000000..6c1e262911767 --- /dev/null +++ b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.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 assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IChat, ISession, SessionStatus } from '../../common/session.js'; +import { ISessionsChangeEvent, ISessionsManagementService } from '../../common/sessionsManagement.js'; +import { groupSortKey, SessionGroupsService } from '../../browser/sessionGroupsService.js'; + +function createSession(id: string): ISession { + return { + sessionId: id, + resource: URI.parse(`session://${id}`), + providerId: 'test', + sessionType: 'test', + icon: Codicon.account, + createdAt: new Date(), + workspace: observableValue(`workspace-${id}`, undefined), + title: observableValue(`title-${id}`, id), + updatedAt: observableValue(`updatedAt-${id}`, new Date()), + status: observableValue(`status-${id}`, SessionStatus.Completed), + changesets: observableValue(`changesets-${id}`, []), + changes: observableValue(`changes-${id}`, []), + modelId: observableValue(`modelId-${id}`, undefined), + mode: observableValue(`mode-${id}`, undefined), + loading: observableValue(`loading-${id}`, false), + isArchived: observableValue(`isArchived-${id}`, false), + isRead: observableValue(`isRead-${id}`, true), + description: observableValue(`description-${id}`, undefined), + lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), + chats: observableValue(`chats-${id}`, []), + mainChat: constObservable(undefined!), + capabilities: { supportsMultipleChats: false }, + }; +} + +suite('SessionGroupsService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + let service: SessionGroupsService; + let storageService: InMemoryStorageService; + let sessionsChangedEmitter: Emitter; + let instantiationService: TestInstantiationService; + + setup(() => { + instantiationService = disposables.add(new TestInstantiationService()); + storageService = disposables.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + sessionsChangedEmitter = disposables.add(new Emitter()); + instantiationService.stub(ISessionsManagementService, { + ...mock(), + onDidChangeSessions: sessionsChangedEmitter.event, + }); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + }); + + test('create group with members and look up membership', () => { + const group = service.createGroup('Group A', ['s1', 's2']); + + assert.strictEqual(service.getGroup(group.id)?.name, 'Group A'); + assert.strictEqual(service.getGroupOfSession('s1'), group.id); + assert.strictEqual(service.getGroupOfSession('s2'), group.id); + assert.deepStrictEqual(service.getSessionIdsInGroup(group.id).sort(), ['s1', 's2']); + }); + + test('a session belongs to at most one group; adding moves it', () => { + const a = service.createGroup('A', ['s1']); + const b = service.createGroup('B'); + + service.addToGroup('s1', b.id); + + assert.strictEqual(service.getGroupOfSession('s1'), b.id); + assert.deepStrictEqual(service.getSessionIdsInGroup(a.id), []); + assert.deepStrictEqual(service.getSessionIdsInGroup(b.id), ['s1']); + }); + + test('remove from group clears membership', () => { + const a = service.createGroup('A', ['s1', 's2']); + service.removeFromGroup('s1'); + + assert.strictEqual(service.getGroupOfSession('s1'), undefined); + assert.deepStrictEqual(service.getSessionIdsInGroup(a.id), ['s2']); + }); + + test('rename group', () => { + const a = service.createGroup('A'); + service.renameGroup(a.id, 'Renamed'); + assert.strictEqual(service.getGroup(a.id)?.name, 'Renamed'); + }); + + test('delete group removes group and membership', () => { + const a = service.createGroup('A', ['s1', 's2']); + service.deleteGroup(a.id); + + assert.strictEqual(service.getGroup(a.id), undefined); + assert.strictEqual(service.getGroupOfSession('s1'), undefined); + assert.strictEqual(service.getGroupOfSession('s2'), undefined); + }); + + test('membership is cleaned up when a session is removed', () => { + const a = service.createGroup('A', ['s1', 's2']); + const session = createSession('s1'); + sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + + assert.strictEqual(service.getGroupOfSession('s1'), undefined); + assert.deepStrictEqual(service.getSessionIdsInGroup(a.id), ['s2']); + }); + + test('empty groups are capped at 3, evicting the oldest', () => { + // Four empty groups created in order; the oldest (g1) should be evicted. + const g1 = service.createGroup('1'); + const g2 = service.createGroup('2'); + const g3 = service.createGroup('3'); + const g4 = service.createGroup('4'); + + const ids = service.getGroups().map(g => g.id); + assert.strictEqual(ids.includes(g1.id), false); + assert.deepStrictEqual([g2, g3, g4].map(g => ids.includes(g.id)), [true, true, true]); + }); + + test('non-empty groups are never evicted by the empty cap', () => { + const kept = service.createGroup('kept', ['s1']); + service.createGroup('e1'); + service.createGroup('e2'); + service.createGroup('e3'); + service.createGroup('e4'); + + assert.strictEqual(service.getGroup(kept.id)?.name, 'kept'); + assert.strictEqual(service.getGroups().filter(g => service.getSessionIdsInGroup(g.id).length === 0).length, 3); + }); + + test('setGroupSortKey overrides placement and persists', () => { + const a = service.createGroup('A', ['s1']); + service.setGroupSortKey(a.id, 12345); + assert.strictEqual(service.getGroup(a.id)?.sortKeyOverride, 12345); + + const reloaded = disposables.add(instantiationService.createInstance(SessionGroupsService)); + assert.strictEqual(reloaded.getGroup(a.id)?.sortKeyOverride, 12345); + }); + + test('state persists across reload', () => { + const a = service.createGroup('Persisted', ['s1', 's2']); + + const reloaded = disposables.add(instantiationService.createInstance(SessionGroupsService)); + assert.strictEqual(reloaded.getGroup(a.id)?.name, 'Persisted'); + assert.strictEqual(reloaded.getGroupOfSession('s1'), a.id); + assert.strictEqual(reloaded.getGroupOfSession('s2'), a.id); + }); + + test('groupSortKey returns the highest member key', () => { + assert.strictEqual(groupSortKey([10, 50, 30]), 50); + assert.strictEqual(groupSortKey([]), undefined); + }); +}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 64c6c42d12c92..3462f6e45799f 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -463,6 +463,7 @@ import './contrib/providers/copilotChatSessions/browser/copilotChatSessions.cont import './contrib/providers/localChatSessions/browser/localChatSessions.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './services/sessions/browser/sessionsListModelService.js'; +import './services/sessions/browser/sessionGroupsService.js'; import './services/agentHostFilter/browser/agentHostFilterService.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changes/browser/changes.contribution.js'; From 26b7c6b22c545bae1fbada8cbcfcda96e0f4bb8e Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 23 Jun 2026 10:09:21 -0700 Subject: [PATCH 039/696] agentHost: add opt-in BYOK language-model bridge for agent-host sessions --- .../common/agentHostClientByokLmChannel.ts | 64 +++++++ .../agentHostStarter.config.contribution.ts | 7 + .../platform/agentHost/common/agentService.ts | 21 +++ .../electron-browser/localAgentHostService.ts | 35 +++- .../electron-main/electronAgentHostStarter.ts | 3 +- .../node/agentHostClientReverseRpc.ts | 44 +++++ .../platform/agentHost/node/agentHostMain.ts | 36 +++- .../agentHost/node/nodeAgentHostStarter.ts | 3 +- .../test/common/agentService.test.ts | 27 ++- .../node/agentHostClientByokLmChannel.test.ts | 68 +++++++ .../node/agentHostClientReverseRpc.test.ts | 61 +++++++ .../agentHost/agentHostByokLmHandler.ts | 154 ++++++++++++++++ .../electron-browser/chat.contribution.ts | 6 + .../agentHostByokLmHandler.test.ts | 168 ++++++++++++++++++ 14 files changed, 681 insertions(+), 16 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts create mode 100644 src/vs/platform/agentHost/node/agentHostClientReverseRpc.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostClientReverseRpc.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts new file mode 100644 index 0000000000000..49f2ebca10af2 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Event } from '../../../base/common/event.js'; +import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { + IAgentHostByokLmHandler, + IByokLmBridgeConnection, + IByokLmChatRequest, + IByokLmChatResult, +} from './agentHostByokLm.js'; + +/** + * IPC channel name used for in-process agent-host → renderer reverse BYOK + * language-model RPCs. The renderer registers a server channel under this + * name on its `MessagePortClient`; the agent host reaches it via + * `server.getChannel(name, c => c.ctx === clientId)` on its + * `UtilityProcessServer`. + * + * Mirrors {@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL} for the reverse FS bridge. + */ +export const AGENT_HOST_CLIENT_BYOK_LM_CHANNEL = 'agentHostClientByokLm'; + +/** + * Wraps an {@link IChannel} (obtained from the agent host's + * `UtilityProcessServer.getChannel`) into an {@link IByokLmBridgeConnection} + * suitable for the node-side {@link IByokLmProxyService}. This is the node end + * of the bridge: `chat()` ships the request to the renderer and resolves with + * the buffered completion the renderer produced from the LM API. + */ +export function createAgentHostClientByokLmConnection(channel: IChannel): IByokLmBridgeConnection { + return { + chat: (request) => channel.call('chat', request) as Promise, + }; +} + +/** + * Server-side channel for in-process reverse BYOK LM RPCs from the local agent + * host. Thin adapter — forwards `chat` calls to the renderer's + * {@link IAgentHostByokLmHandler} (backed by `ILanguageModelsService`). + */ +export class AgentHostClientByokLmChannel implements IServerChannel { + + constructor( + @IAgentHostByokLmHandler private readonly _handler: IAgentHostByokLmHandler, + ) { } + + listen(_ctx: unknown, event: string): Event { + throw new Error(`No event '${event}' on AgentHostClientByokLmChannel`); + } + + async call(_ctx: unknown, command: string, arg?: unknown): Promise { + switch (command) { + case 'chat': { + const result = await this._handler.chat(arg as IByokLmChatRequest, CancellationToken.None); + return result as T; + } + } + throw new Error(`Unknown command '${command}' on AgentHostClientByokLmChannel`); + } +} diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 2d79db08f7471..129cf2c8c691c 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -9,6 +9,7 @@ import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurati import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { + AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, @@ -54,6 +55,12 @@ configurationRegistry.registerConfiguration({ name: 'Claude3PIntegration', }, }, + [AgentHostByokModelsEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), + default: false, + tags: ['experimental', 'advanced'], + }, [AgentHostCodexAgentEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.codexAgent.enabled', "When enabled, the agent host registers the Codex provider (subject to the Codex SDK being reachable). Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3de8693ee6d39..ceda4fe022ca2 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -86,6 +86,16 @@ export const AgentHostClaudeAgentEnabledSettingId = 'chat.agentHost.claudeAgent. */ export const AgentHostCodexAgentEnabledSettingId = 'chat.agentHost.codexAgent.enabled'; +/** + * Configuration key controlling whether the agent host wires up the BYOK + * ("bring your own key") language-model bridge: the renderer LM handler, the + * reverse-RPC channel, and the node-side OpenAI proxy + bridge registry. When + * `false` (the default), none of the BYOK additions are registered on either + * side, so extension-provided BYOK models are never reachable from agent-host + * sessions. The agent host process must be restarted for changes to take effect. + */ +export const AgentHostByokModelsEnabledSettingId = 'chat.agentHost.byokModels.enabled'; + /** * Optional override that points at an **SDK root directory** containing a * `node_modules/@anthropic-ai/claude-agent-sdk` subtree. When set, the agent @@ -111,6 +121,13 @@ export const AgentHostClaudeAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_AGENT */ export const AgentHostCodexAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CODEX_AGENT_ENABLED'; +/** + * Environment variable form of {@link AgentHostByokModelsEnabledSettingId}. + * Set by the agent host starters from the setting. Accepts `'true'` / + * `'false'`; absent means "default" (`false`). + */ +export const AgentHostByokModelsEnabledEnvVar = 'VSCODE_AGENT_HOST_BYOK_MODELS_ENABLED'; + /** * Resolves the effective enable state for a Claude/Codex provider from the * env-var value forwarded by the starter. Recognized values (case- and @@ -386,6 +403,7 @@ export interface IAgentSdkStarterSettings { readonly codexBinaryArgs?: readonly string[]; readonly claudeAgentEnabled?: boolean; readonly codexAgentEnabled?: boolean; + readonly byokModelsEnabled?: boolean; } export function buildAgentSdkEnv( @@ -410,6 +428,9 @@ export function buildAgentSdkEnv( if (settings.codexAgentEnabled !== undefined) { setIfMissing(AgentHostCodexAgentEnabledEnvVar, settings.codexAgentEnabled ? 'true' : 'false'); } + if (settings.byokModelsEnabled !== undefined) { + setIfMissing(AgentHostByokModelsEnabledEnvVar, settings.byokModelsEnabled ? 'true' : 'false'); + } return out; } diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 45e744031786f..ff9a7a44858c7 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -8,14 +8,14 @@ import { Emitter, Relay } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IReference } from '../../../base/common/lifecycle.js'; import { IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { getDelayedChannel, IChannelServer, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostByokModelsEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -28,6 +28,7 @@ import { StateComponents, ROOT_STATE_URI, parseChatUri, type RootState } from '. import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } from '../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, AgentHostSessionSyncEnabledConfigKey, SESSION_SYNC_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; @@ -143,10 +144,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos // calls (vscode-agent-client filesystem reads) back to this renderer // via `IPCServer.getChannel(name, c => c.ctx === clientId)`. const client = store.add(new MessagePortClient(port, this.clientId)); - // Serve filesystem reverse-RPCs from the local file service. The - // agent host registers an authority on its - // AgentHostClientFileSystemProvider that calls back through this channel. - client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, this._instantiationService.createInstance(AgentHostClientResourceChannel, this._ahpLogger)); + registerAgentHostClientChannels(client, this._instantiationService, this._ahpLogger, this._configurationService.getValue(AgentHostByokModelsEnabledSettingId) === true); this._clientEventually.complete(client); this._updateTelemetryLevel(); this._updateSessionSyncEnabled(); @@ -342,3 +340,28 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._connectionTracker.getInspectInfo(tryEnable); } } + +/** + * Register the reverse-RPC server channels every in-process renderer exposes to + * the agent host's {@link UtilityProcessServer}: the filesystem resource bridge + * ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}) and the BYOK language-model + * bridge ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The agent host reaches + * these via `server.getChannel(name, c => c.ctx === clientId)`. + */ +export function registerAgentHostClientChannels( + client: IChannelServer, + instantiationService: IInstantiationService, + ahpLogger: AhpJsonlLogger | undefined, + byokEnabled: boolean, +): void { + // Serve filesystem reverse-RPCs from the local file service. The agent host + // registers an authority on its AgentHostClientFileSystemProvider that calls + // back through this channel. + client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, instantiationService.createInstance(AgentHostClientResourceChannel, ahpLogger)); + // Serve BYOK language-model reverse-RPCs from the renderer LM API, gated + // behind `chat.agentHost.byokModels.enabled`. When disabled, the node-side + // proxy + registry are also skipped, so the channel would never be called. + if (byokEnabled) { + client.registerChannel(AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, instantiationService.createInstance(AgentHostClientByokLmChannel)); + } +} diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index 054f96ca48969..4dd180c31e0ca 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,7 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHost.config.contribution.js'; import '../common/agentHostStarter.config.contribution.js'; @@ -75,6 +75,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt codexBinaryArgs: this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId), codexAgentEnabled: this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId), + byokModelsEnabled: this._configurationService.getValue(AgentHostByokModelsEnabledSettingId), }, process.env); // Translate `chat.agentHost.otel.*` settings into the env vars consumed by diff --git a/src/vs/platform/agentHost/node/agentHostClientReverseRpc.ts b/src/vs/platform/agentHost/node/agentHostClientReverseRpc.ts new file mode 100644 index 0000000000000..6f29a58d65010 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostClientReverseRpc.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, createAgentHostClientResourceConnection } from '../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, createAgentHostClientByokLmConnection } from '../common/agentHostClientByokLmChannel.js'; +import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; +import { IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; + +/** + * Wire the per-connection reverse-RPC bridges every in-process renderer exposes + * to the agent host's `UtilityProcessServer`: the filesystem resource bridge + * ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}) and the BYOK language-model + * bridge ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The filesystem bridge lets + * the agent host read files from the connected renderer's workspace; the BYOK + * bridge lets the node-side OpenAI proxy reach the renderer's LM API for + * extension-provided models. Disposing the result tears both bridges down for + * that connection. + * + * @param getChannel Resolves a renderer server channel by name, already scoped + * to the connection's `clientId` (e.g. `name => server.getChannel(name, c => c.ctx === clientId)`). + */ +export function registerAgentHostClientReverseRpc( + clientId: string, + getChannel: (channelName: string) => IChannel, + clientFileSystemProvider: Pick, + byokLmBridgeRegistry: IByokLmBridgeRegistry | undefined, +): IDisposable { + const store = new DisposableStore(); + const fsConnection = createAgentHostClientResourceConnection(getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL)); + store.add(clientFileSystemProvider.registerAuthority(clientId, fsConnection)); + // The BYOK bridge is wired only when the feature is enabled — the caller + // passes `undefined` when `chat.agentHost.byokModels.enabled` is off. In that + // case the renderer also skips registering the BYOK server channel, so there + // is nothing to bridge. + if (byokLmBridgeRegistry) { + const byokLmConnection = createAgentHostClientByokLmConnection(getChannel(AGENT_HOST_CLIENT_BYOK_LM_CHANNEL)); + store.add(byokLmBridgeRegistry.register(clientId, byokLmConnection)); + } + return store; +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 20796dd4debdb..14cfe9dd01939 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -15,7 +15,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledEnvVar, AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; import { AgentService } from './agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; @@ -28,6 +28,8 @@ import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; +import { ByokLmProxyService, IByokLmProxyService } from './copilot/byokLmProxyService.js'; +import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; @@ -63,7 +65,7 @@ import { NodeWorkerDiffComputeService } from './diffComputeService.js'; import { IEditSurvivalReporterFactory, EditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; -import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, createAgentHostClientResourceConnection } from '../common/agentHostClientResourceChannel.js'; +import { registerAgentHostClientReverseRpc } from './agentHostClientReverseRpc.js'; import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { AgentHostGitService } from './agentHostGitService.js'; @@ -131,6 +133,11 @@ async function startAgentHost(): Promise { // Create the real service implementation that lives in this process let agentService: AgentService; let instantiationService: IInstantiationService; + // Gate all BYOK agent-host additions (proxy, bridge registry, reverse-RPC + // bridge) behind the opt-in `chat.agentHost.byokModels.enabled` setting, + // forwarded from the renderer as an env var. When off, none of the BYOK + // wiring is created here and the renderer skips the BYOK server channel. + const byokLmEnabled = isAgentEnabled(process.env[AgentHostByokModelsEnabledEnvVar], false); try { // Build the DI container early so the git service can be created via // `createInstance` (it needs IFileService + INativeEnvironmentService). @@ -172,6 +179,16 @@ async function startAgentHost(): Promise { diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); diServices.set(ICodexProxyService, codexProxyService); + // BYOK language-model proxy + bridge registry, gated behind + // `chat.agentHost.byokModels.enabled`. The registry is populated per + // renderer connection below; the proxy lazily binds when the session + // launcher starts it for a session that selected a forwarded BYOK model. + if (byokLmEnabled) { + const byokLmBridgeRegistry = new ByokLmBridgeRegistry(); + diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); + const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); + diServices.set(IByokLmProxyService, byokLmProxyService); + } const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); @@ -217,10 +234,12 @@ async function startAgentHost(): Promise { // in-process renderer-to-utility-process MessagePort transport). const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); + const byokLmBridgeRegistry = byokLmEnabled ? instantiationService.invokeFunction(accessor => accessor.get(IByokLmBridgeRegistry)) : undefined; // Wire reverse-RPC for in-process renderer connections. The renderer's - // `MessagePortClient` ctx is its `clientId`, and it exposes - // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` for filesystem reads. + // `MessagePortClient` ctx is its `clientId`, and it exposes the + // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` (filesystem reads) and + // `AGENT_HOST_CLIENT_BYOK_LM_CHANNEL` (BYOK language-model calls). if (server instanceof UtilityProcessServer) { const authorityRegistrations = new Map(); const registerConnection = (connection: (typeof server.connections)[number]) => { @@ -231,9 +250,12 @@ async function startAgentHost(): Promise { if (typeof clientId !== 'string' || !clientId) { return; } - const channel = server.getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, c => c.ctx === clientId); - const fsConnection = createAgentHostClientResourceConnection(channel); - authorityRegistrations.set(connection, clientFileSystemProvider.registerAuthority(clientId, fsConnection)); + authorityRegistrations.set(connection, registerAgentHostClientReverseRpc( + clientId, + channelName => server.getChannel(channelName, c => c.ctx === clientId), + clientFileSystemProvider, + byokLmBridgeRegistry, + )); }; disposables.add(server.onDidAddConnection(registerConnection)); disposables.add(server.onDidRemoveConnection(connection => { diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index 8016d0f293cbc..b4b2f8cc43b57 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,7 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; import '../common/agentHostStarter.config.contribution.js'; /** @@ -83,6 +83,7 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte codexBinaryArgs: this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId), codexAgentEnabled: this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId), + byokModelsEnabled: this._configurationService.getValue(AgentHostByokModelsEnabledSettingId), }, process.env); Object.assign(env, sdkEnv); diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts index cd29d5fdfc9f9..1d2114f9cd3f5 100644 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentSession, isAgentEnabled } from '../../common/agentService.js'; +import { AgentHostByokModelsEnabledEnvVar, AgentSession, buildAgentSdkEnv, isAgentEnabled } from '../../common/agentService.js'; suite('AgentSession namespace', () => { @@ -66,3 +66,28 @@ suite('isAgentEnabled', () => { }); } }); + +suite('buildAgentSdkEnv (BYOK gate forwarding)', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('forwards byokModelsEnabled=true as the enable env var', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: true }, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], 'true'); + }); + + test('forwards byokModelsEnabled=false as the disable env var', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: false }, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], 'false'); + }); + + test('omits the env var when byokModelsEnabled is undefined', () => { + const env = buildAgentSdkEnv({}, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], undefined); + }); + + test('lets an inherited env var win over the setting (developer override)', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: true }, { [AgentHostByokModelsEnabledEnvVar]: 'false' }); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts new file mode 100644 index 0000000000000..732798c5d450d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IAgentHostByokLmHandler, IByokLmChatRequest, IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { AgentHostClientByokLmChannel, createAgentHostClientByokLmConnection } from '../../common/agentHostClientByokLmChannel.js'; + +suite('agentHostClientByokLmChannel', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function handlerOf(impl: (request: IByokLmChatRequest) => Promise): IAgentHostByokLmHandler { + return { _serviceBrand: undefined, chat: (request) => impl(request) }; + } + + /** + * Wire the node-side connection straight to the renderer server channel, + * standing in for the MessagePort transport so the full request → handler → + * response round-trip can be exercised without the renderer or the SDK. + */ + function bridge(handler: IAgentHostByokLmHandler) { + const server = new AgentHostClientByokLmChannel(handler); + const channel: IChannel = { + call(command: string, arg?: unknown): Promise { + return server.call(null, command, arg); + }, + listen(event: string): Event { + return server.listen(null, event); + }, + }; + return createAgentHostClientByokLmConnection(channel); + } + + test('round-trips a chat request to the handler and back', async () => { + let seen: IByokLmChatRequest | undefined; + const connection = bridge(handlerOf(async (request) => { + seen = request; + return { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }; + })); + + const request: IByokLmChatRequest = { vendor: 'acme', modelId: 'm', messages: [{ role: 'user', content: 'ping' }] }; + const result = await connection.chat(request); + + assert.deepStrictEqual(seen, request); + assert.deepStrictEqual(result, { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }); + }); + + test('forwards a bridge error result unchanged', async () => { + const connection = bridge(handlerOf(async () => ({ content: '', error: 'no model' }))); + const result = await connection.chat({ vendor: 'v', modelId: 'm', messages: [] }); + assert.strictEqual(result.error, 'no model'); + }); + + test('rejects unknown channel commands', async () => { + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' }))); + await assert.rejects(() => server.call(null, 'frobnicate'), /Unknown command/); + }); + + test('exposes no events', () => { + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' }))); + assert.throws(() => server.listen(null, 'anything'), /No event/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostClientReverseRpc.test.ts b/src/vs/platform/agentHost/test/node/agentHostClientReverseRpc.test.ts new file mode 100644 index 0000000000000..2e736a8bcb129 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostClientReverseRpc.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { type IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IByokLmBridgeConnection } from '../../common/agentHostByokLm.js'; +import type { AgentHostClientFileSystemProvider } from '../../common/agentHostClientFileSystemProvider.js'; +import { registerAgentHostClientReverseRpc } from '../../node/agentHostClientReverseRpc.js'; +import type { IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; + +suite('registerAgentHostClientReverseRpc', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + class NullChannel extends mock() { } + const getChannel = (_channelName: string): IChannel => new NullChannel(); + + function fsProvider(): { provider: Pick; registered: string[] } { + const registered: string[] = []; + return { + registered, + provider: { registerAuthority: (clientId: string): IDisposable => { registered.push(clientId); return toDisposable(() => { }); } }, + }; + } + + function bridgeRegistry(): { registry: IByokLmBridgeRegistry; registered: string[] } { + const registered: string[] = []; + return { + registered, + registry: { + _serviceBrand: undefined, + register: (clientId: string, _connection: IByokLmBridgeConnection): IDisposable => { registered.push(clientId); return toDisposable(() => { }); }, + get: () => undefined, + getActive: () => undefined, + }, + }; + } + + test('registers both the filesystem and BYOK bridges when a registry is provided', () => { + const fs = fsProvider(); + const reg = bridgeRegistry(); + const store = registerAgentHostClientReverseRpc('client-1', getChannel, fs.provider, reg.registry); + assert.deepStrictEqual(fs.registered, ['client-1']); + assert.deepStrictEqual(reg.registered, ['client-1']); + store.dispose(); + }); + + test('registers only the filesystem bridge when the registry is undefined (BYOK gated off)', () => { + const fs = fsProvider(); + const reg = bridgeRegistry(); + const store = registerAgentHostClientReverseRpc('client-1', getChannel, fs.provider, undefined); + assert.deepStrictEqual(fs.registered, ['client-1']); + assert.deepStrictEqual(reg.registered, []); + store.dispose(); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts new file mode 100644 index 0000000000000..df95d758ba7c0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { + IAgentHostByokLmHandler, + IByokLmChatMessage, + IByokLmChatRequest, + IByokLmChatResult, + IByokLmToolCall, +} from '../../../../../../platform/agentHost/common/agentHostByokLm.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { + ChatMessageRole, + IChatMessage, + IChatMessagePart, + ILanguageModelChatRequestOptions, + ILanguageModelsService, +} from '../../../common/languageModels.js'; + +/** + * Renderer-side {@link IAgentHostByokLmHandler}. Services BYOK chat requests + * forwarded by the node agent host's OpenAI proxy by calling the VS Code LM + * API for the matching extension-registered model. + * + * The bridge DTOs are plain/serializable; this class is the single place that + * translates them to and from the `workbench/contrib/chat` LM types. + */ +export class AgentHostByokLmHandler extends Disposable implements IAgentHostByokLmHandler { + + declare readonly _serviceBrand: undefined; + + constructor( + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async chat(request: IByokLmChatRequest, token: CancellationToken): Promise { + const modelIdentifier = this._resolveModelIdentifier(request.vendor, request.modelId); + if (!modelIdentifier) { + return { content: '', error: `No BYOK model found for ${request.vendor}/${request.modelId}` }; + } + + const messages = request.messages.map(message => this._toChatMessage(message)); + const tools = request.tools?.length + ? request.tools.map(tool => ({ + name: tool.name, + description: tool.description ?? '', + inputSchema: tool.parametersSchema, + })) + : undefined; + const options: ILanguageModelChatRequestOptions = { + modelOptions: request.modelOptions, + ...(tools ? { tools } : {}), + }; + + try { + const response = await this._languageModelsService.sendChatRequest(modelIdentifier, undefined, messages, options, token); + + let content = ''; + const toolCalls: IByokLmToolCall[] = []; + const streaming = (async () => { + for await (const part of response.stream) { + const parts = Array.isArray(part) ? part : [part]; + for (const p of parts) { + if (p.type === 'text') { + content += p.value; + } else if (p.type === 'tool_use') { + toolCalls.push({ + id: p.toolCallId, + name: p.name, + argumentsJson: JSON.stringify(p.parameters ?? {}), + }); + } + } + } + })(); + + await Promise.all([response.result, streaming]); + return { content, toolCalls: toolCalls.length ? toolCalls : undefined }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._logService.warn(`[AgentHostByokLmHandler] chat request failed for ${request.vendor}/${request.modelId}: ${message}`); + return { content: '', error: message }; + } + } + + /** + * Find the LM API identifier for a BYOK model addressed by its vendor and + * provider-local id (the `provider/id` selection id the picker surfaced). + */ + private _resolveModelIdentifier(vendor: string, modelId: string): string | undefined { + for (const identifier of this._languageModelsService.getLanguageModelIds()) { + const metadata = this._languageModelsService.lookupLanguageModel(identifier); + if (metadata?.isBYOK && metadata.vendor === vendor && metadata.id === modelId) { + return identifier; + } + } + return undefined; + } + + private _toChatMessage(message: IByokLmChatMessage): IChatMessage { + const content: IChatMessagePart[] = []; + if (message.content) { + content.push({ type: 'text', value: message.content }); + } + + if (message.role === 'assistant' && message.toolCalls?.length) { + for (const call of message.toolCalls) { + content.push({ + type: 'tool_use', + name: call.name, + toolCallId: call.id, + parameters: this._safeParseJson(call.argumentsJson), + }); + } + } + + if (message.role === 'tool' && message.toolCallId) { + content.push({ + type: 'tool_result', + toolCallId: message.toolCallId, + value: [{ type: 'text', value: message.content }], + }); + // Tool results ride on a user-role message in the LM API. + return { role: ChatMessageRole.User, content }; + } + + return { role: this._toChatRole(message.role), content }; + } + + private _toChatRole(role: IByokLmChatMessage['role']): ChatMessageRole { + switch (role) { + case 'system': return ChatMessageRole.System; + case 'assistant': return ChatMessageRole.Assistant; + case 'user': + case 'tool': + default: return ChatMessageRole.User; + } + } + + private _safeParseJson(json: string): unknown { + try { + return JSON.parse(json); + } catch { + return {}; + } + } +} diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 39b6e252c2ecd..eb1e86429b6f3 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -32,6 +32,7 @@ import { IWorkbenchLayoutService } from '../../../services/layout/browser/layout import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ACTION_ID_NEW_CHAT, CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../browser/actions/chatActions.js'; import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; +import { AgentHostByokLmHandler } from '../browser/agentSessions/agentHost/agentHostByokLmHandler.js'; import { AgentHostSessionListContribution } from '../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; import { AgentHostTerminalContribution } from '../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { AgentHostCopilotPromptContribution } from '../browser/agentSessions/agentHost/agentHostCopilotPromptContribution.js'; @@ -40,6 +41,7 @@ import { IAgentSessionsService } from '../browser/agentSessions/agentSessionsSer import { ChatViewPaneTarget, IChatWidgetService } from '../browser/chat.js'; import { ChatSessionPosition, openChatSession } from '../browser/chatSessions/chatSessions.contribution.js'; import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; import { type AgentInfo, type RootState } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IChatService } from '../common/chatService/chatService.js'; @@ -268,6 +270,10 @@ registerWorkbenchContribution2(AgentHostCopilotPromptContribution.ID, AgentHostC registerWorkbenchContribution2(OpenWorkspaceInAgentsContribution.ID, OpenWorkspaceInAgentsContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(AgentsHandoffInputTipContribution.ID, AgentsHandoffInputTipContribution, WorkbenchPhase.Eventually); +// Renderer-side BYOK language-model handler that backs the node agent host's +// OpenAI proxy. Lazily instantiated when AgentHostClientByokLmChannel resolves it. +registerSingleton(IAgentHostByokLmHandler, AgentHostByokLmHandler, InstantiationType.Delayed); + // How long to wait for the agent host to surface an AgentInfo before // throwing an error. Long enough for normal startup, short enough to avoid // hanging automation indefinitely if the agent host is disabled or fails diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts new file mode 100644 index 0000000000000..a16a68bac67f7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.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 assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import type { IByokLmChatRequest } from '../../../../../../platform/agentHost/common/agentHostByokLm.js'; +import { AgentHostByokLmHandler } from '../../../browser/agentSessions/agentHost/agentHostByokLmHandler.js'; +import { ChatMessageRole, IChatMessage, IChatResponsePart, ILanguageModelChatMetadata, ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelsService } from '../../../common/languageModels.js'; + +interface ICapturedRequest { + modelId: string; + messages: IChatMessage[]; + options: ILanguageModelChatRequestOptions; +} + +/** + * Fake LM API service: resolves a small fixed model set and replays a + * scripted response stream, capturing what the handler forwarded. Stands in + * for the renderer's real `ILanguageModelsService` so the bridge handler can be + * exercised without any extension or model provider. + */ +class TestLanguageModelsService extends mock() { + + captured: ICapturedRequest | undefined; + + constructor( + private readonly _models: ReadonlyMap, + private readonly _respond: (request: ICapturedRequest) => ILanguageModelChatResponse, + ) { + super(); + } + + override getLanguageModelIds(): string[] { + return [...this._models.keys()]; + } + + override lookupLanguageModel(modelId: string): ILanguageModelChatMetadata | undefined { + return this._models.get(modelId); + } + + override async sendChatRequest(modelId: string, _from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, _token: CancellationToken): Promise { + this.captured = { modelId, messages, options }; + return this._respond(this.captured); + } +} + +function byokModel(vendor: string, id: string): ILanguageModelChatMetadata { + return { + extension: new ExtensionIdentifier('test.byok'), + name: `${vendor} ${id}`, + id, + vendor, + version: '1.0.0', + family: 'test', + maxInputTokens: 1000, + maxOutputTokens: 1000, + isDefaultForLocation: {}, + isBYOK: true, + }; +} + +function responseOf(parts: IChatResponsePart[]): ILanguageModelChatResponse { + return { + stream: (async function* () { + for (const part of parts) { + yield part; + } + })(), + result: Promise.resolve(undefined), + }; +} + +suite('AgentHostByokLmHandler', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHandler(service: ILanguageModelsService): AgentHostByokLmHandler { + return store.add(new AgentHostByokLmHandler(service, new NullLogService())); + } + + test('resolves the BYOK model and buffers text + tool calls', async () => { + const service = new TestLanguageModelsService( + new Map([['id-acme-claude', byokModel('acme', 'claude')]]), + () => responseOf([ + { type: 'text', value: 'hello ' }, + { type: 'text', value: 'world' }, + { type: 'tool_use', name: 'getWeather', toolCallId: 't1', parameters: { city: 'NYC' } }, + ]), + ); + const handler = createHandler(service); + + const result = await handler.chat( + { vendor: 'acme', modelId: 'claude', messages: [{ role: 'user', content: 'hi' }] }, + CancellationToken.None, + ); + + assert.strictEqual(service.captured?.modelId, 'id-acme-claude'); + assert.deepStrictEqual(result, { + content: 'hello world', + toolCalls: [{ id: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], + }); + }); + + test('maps bridge messages to LM API chat messages', async () => { + const service = new TestLanguageModelsService( + new Map([['id', byokModel('acme', 'claude')]]), + () => responseOf([{ type: 'text', value: 'ok' }]), + ); + const handler = createHandler(service); + + await handler.chat( + { + vendor: 'acme', + modelId: 'claude', + messages: [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: 'hi' }, + { role: 'assistant', content: '', toolCalls: [{ id: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }, + { role: 'tool', content: 'sunny', toolCallId: 't1' }, + ], + }, + CancellationToken.None, + ); + + assert.deepStrictEqual(service.captured?.messages, [ + { role: ChatMessageRole.System, content: [{ type: 'text', value: 'be helpful' }] }, + { role: ChatMessageRole.User, content: [{ type: 'text', value: 'hi' }] }, + { role: ChatMessageRole.Assistant, content: [{ type: 'tool_use', name: 'getWeather', toolCallId: 't1', parameters: { city: 'NYC' } }] }, + // A `tool` message rides on a User-role message; its text is emitted both as a + // leading text part (shared `if (message.content)` branch) and inside the tool_result. + { role: ChatMessageRole.User, content: [{ type: 'text', value: 'sunny' }, { type: 'tool_result', toolCallId: 't1', value: [{ type: 'text', value: 'sunny' }] }] }, + ]); + }); + + test('returns an error result when no BYOK model matches', async () => { + const service = new TestLanguageModelsService(new Map(), () => responseOf([])); + const handler = createHandler(service); + + const result = await handler.chat( + { vendor: 'acme', modelId: 'missing', messages: [] } satisfies IByokLmChatRequest, + CancellationToken.None, + ); + + assert.strictEqual(result.content, ''); + assert.ok(result.error?.includes('acme/missing'), `expected error to name the model: ${result.error}`); + }); + + test('returns an error result when the LM request throws', async () => { + const service = new TestLanguageModelsService( + new Map([['id', byokModel('acme', 'claude')]]), + () => { throw new Error('provider exploded'); }, + ); + const handler = createHandler(service); + + const result = await handler.chat( + { vendor: 'acme', modelId: 'claude', messages: [{ role: 'user', content: 'hi' }] }, + CancellationToken.None, + ); + + assert.deepStrictEqual(result, { content: '', error: 'provider exploded' }); + }); +}); From 471e60b53d7e850f8399bbc4622d65a56c692bf4 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 23 Jun 2026 18:18:50 +0100 Subject: [PATCH 040/696] Refactor floating panels layout for improved edge ownership (#322543) * Enhance floating panels layout: add outer margins for sidebars and editors Co-authored-by: Copilot * Adjust floating panels layout: update margin handling and introduce outer edge ownership logic Co-authored-by: Copilot * Refine comments for floating panels: clarify edge ownership logic and gutter application * Refactor floating panels layout: implement outer gutter logic for edge ownership and update related CSS classes Co-authored-by: Copilot * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: mrleemurray Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../browser/media/floatingPanels.css | 31 ++++- src/vs/workbench/browser/part.ts | 17 ++- .../browser/parts/editor/editorPart.ts | 21 +++- .../browser/parts/paneCompositePart.ts | 47 ++++++-- .../services/layout/browser/layoutService.ts | 109 ++++++++++++++++++ 5 files changed, 205 insertions(+), 20 deletions(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 16a28090628eb..0148417e58c59 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -11,8 +11,9 @@ * margin, border and rounded corners — echoing the agents window design. The * matching content dimensions are reduced in code (AbstractPaneCompositePart) so * the inner layout stays in sync with this CSS. The margin and the 1px border - * must match `FLOATING_PANEL_MARGIN`, `FLOATING_AUXBAR_EXTRA_RIGHT` and the border inset - * used there. Colors mirror the agents window (`agentsPanel.*` tokens). + * must match `FLOATING_PANEL_MARGIN` and the border inset used there. The outermost + * card on a window edge gets a doubled outer gutter (`.floating-part-outer-*`). + * Colors mirror the agents window (`agentsPanel.*` tokens). * * Uses `--vscode-spacing-size60` (6px); keep this in sync with * `FLOATING_PANEL_MARGIN` in code. @@ -47,8 +48,17 @@ margin-top: 0; } -/* Secondary side bar sits at the window edge — give it a doubled right gutter. */ -.monaco-workbench.floating-panels .part.auxiliarybar { +/* + * When a floating pane composite (primary side bar, secondary side bar or a vertical + * panel) is the outermost card on a window edge, it adopts a doubled outer gutter so + * its contents do not hug the window edge. The owning edge is toggled in code (kept in + * sync with the inset reservation in `AbstractPaneCompositePart`). + */ +.monaco-workbench.floating-panels .part.floating-part-outer-left { + margin-left: calc(var(--vscode-spacing-size60) * 2); +} + +.monaco-workbench.floating-panels .part.floating-part-outer-right { margin-right: calc(var(--vscode-spacing-size60) * 2); } @@ -58,6 +68,19 @@ margin-top: 0; } +/* + * When the editor becomes the outermost card on a side (no floating part sits + * between it and the window edge) it adopts the same doubled gutter the side/aux + * bars use. Kept in sync with the margin reservation in `EditorPart.layout`. + */ +.monaco-workbench.floating-panels > .monaco-grid-view .part.editor.floating-editor-outer-left { + margin-left: calc(var(--vscode-spacing-size60) * 2); +} + +.monaco-workbench.floating-panels > .monaco-grid-view .part.editor.floating-editor-outer-right { + margin-right: calc(var(--vscode-spacing-size60) * 2); +} + /* The shell backdrop behind the floating cards matches the editor background. */ .monaco-workbench.floating-panels > .monaco-grid-view { background-color: var(--vscode-editor-background); diff --git a/src/vs/workbench/browser/part.ts b/src/vs/workbench/browser/part.ts index 46eaa3defe92e..e7b9278318f58 100644 --- a/src/vs/workbench/browser/part.ts +++ b/src/vs/workbench/browser/part.ts @@ -161,10 +161,23 @@ export abstract class Part extends Componen } private relayout() { - if (this.dimension && this.contentPosition) { - this.layout(this.dimension.width, this.dimension.height, this.contentPosition.top, this.contentPosition.left); + const dimension = this.getRelayoutDimension(); + if (dimension && this.contentPosition) { + this.layout(dimension.width, dimension.height, this.contentPosition.top, this.contentPosition.left); } } + + /** + * The dimension to use when the part re-lays out itself in response to internal + * changes (e.g. title, header or footer visibility). Subclasses that reduce the + * dimension passed to {@link layout} (for example to reserve space for a floating + * card margin) must override this to return the original, unreduced dimension so + * the reduction is not applied repeatedly on every relayout. + */ + protected getRelayoutDimension(): Dimension | undefined { + return this._dimension; + } + /** * Layout title and content area in the given dimension. */ diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index ff3aef63218c1..4c115be378348 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -24,7 +24,7 @@ import { EditorDropTarget } from './editorDropTarget.js'; import { Color } from '../../../../base/common/color.js'; import { CenteredViewLayout, CenteredViewState } from '../../../../base/browser/ui/centered/centeredViewLayout.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; -import { Parts, IWorkbenchLayoutService, Position, FLOATING_PANEL_MARGIN } from '../../../services/layout/browser/layoutService.js'; +import { Parts, IWorkbenchLayoutService, Position, FLOATING_PANEL_MARGIN, getFloatingOuterEdgeOwners } from '../../../services/layout/browser/layoutService.js'; import { DeepPartial, assertType } from '../../../../base/common/types.js'; import { CompositeDragAndDropObserver } from '../../dnd.js'; import { DeferredPromise, Promises } from '../../../../base/common/async.js'; @@ -1370,8 +1370,25 @@ export class EditorPart extends Part implements IEditorPart, // (auxiliary editor windows do not apply the matching CSS). The matching // `margin` is applied in CSS (`.floating-panels .part.editor`). if (this.windowId === mainWindow.vscodeWindowId && this.layoutService.isFloatingPanelsEnabled()) { - width = Math.max(0, width - FLOATING_PANEL_MARGIN * 2); + + // When the editor becomes the outermost card on a side (no floating part + // sits between it and the window edge) it adopts the same doubled gutter the + // side/aux bars use, so its contents do not hug the window edge. The matching + // margins are applied in CSS via the toggled classes below. + const owners = getFloatingOuterEdgeOwners(this.layoutService); + const outerLeft = owners.left === Parts.EDITOR_PART; + const outerRight = owners.right === Parts.EDITOR_PART; + + const leftMargin = outerLeft ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; + const rightMargin = outerRight ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; + + width = Math.max(0, width - leftMargin - rightMargin); height = Math.max(0, height - FLOATING_PANEL_MARGIN); + + this.element.classList.toggle('floating-editor-outer-left', outerLeft); + this.element.classList.toggle('floating-editor-outer-right', outerRight); + } else { + this.element.classList.remove('floating-editor-outer-left', 'floating-editor-outer-right'); } // Layout contents diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index b79ebabd0ef35..9a6763399ae70 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -12,7 +12,7 @@ import { IPaneComposite } from '../../common/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../common/views.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IView } from '../../../base/browser/ui/grid/grid.js'; -import { IWorkbenchLayoutService, Parts, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN } from '../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges } from '../../services/layout/browser/layoutService.js'; import { CompositePart, ICompositePartOptions, ICompositeTitleLabel } from './compositePart.js'; import { IPaneCompositeBarOptions, PaneCompositeBar } from './paneCompositeBar.js'; import { Dimension, EventHelper, trackFocus, $, addDisposableListener, EventType, prepend, getWindow } from '../../../base/browser/dom.js'; @@ -110,13 +110,6 @@ export abstract class AbstractPaneCompositePart extends CompositePart | undefined = undefined; protected contentDimension: Dimension | undefined; + private floatingLayoutDimension: Dimension | undefined; constructor( readonly partId: SINGLE_WINDOW_PARTS, @@ -599,6 +593,11 @@ export abstract class AbstractPaneCompositePart extends CompositePart + // innermost. The editor is the innermost terminal owner (handled in the resolver). + const sideBarSideParts: SINGLE_WINDOW_PARTS[] = [Parts.ACTIVITYBAR_PART, Parts.SIDEBAR_PART]; + const auxSideParts: SINGLE_WINDOW_PARTS[] = [Parts.AUXILIARYBAR_PART]; + const panelParts: SINGLE_WINDOW_PARTS[] = [Parts.PANEL_PART]; + const leftOuterParts: SINGLE_WINDOW_PARTS[] = [...(sideBarLeft ? sideBarSideParts : auxSideParts), ...(panelInLeftSequence ? panelParts : [])]; + const rightOuterParts: SINGLE_WINDOW_PARTS[] = [...(sideBarLeft ? auxSideParts : sideBarSideParts), ...(panelInRightSequence ? panelParts : [])]; + + return { + left: resolveFloatingOuterOwner(layoutService, leftOuterParts), + right: resolveFloatingOuterOwner(layoutService, rightOuterParts) + }; +} + +function resolveFloatingOuterOwner(layoutService: IWorkbenchLayoutService, outerParts: SINGLE_WINDOW_PARTS[]): Parts | undefined { + for (const part of outerParts) { + if (!layoutService.isVisible(part)) { + continue; + } + + // The activity bar hugs the window edge but is not a floating card. + return part === Parts.ACTIVITYBAR_PART ? undefined : part; + } + + // Nothing else sits on this edge: the editor is the outermost (central) card. + return Parts.EDITOR_PART; +} + +/** + * The window edges on which the given part is the outermost floating card and should + * therefore receive a doubled outer gutter. A part can own both edges at once (notably + * a horizontal bottom/top panel that spans the full width when the bars beside it are + * hidden or not full-height). Convenience wrapper around {@link getFloatingOuterEdgeOwners}. + */ +export function getFloatingOuterGutterEdges(layoutService: IWorkbenchLayoutService, partId: Parts): { left: boolean; right: boolean } { + if (!layoutService.isFloatingPanelsEnabled()) { + return { left: false, right: false }; + } + + // A horizontal (bottom/top) panel can reach both window edges simultaneously, so it + // is not captured by the single-owner-per-edge model and is resolved separately. + if (partId === Parts.PANEL_PART && isHorizontal(layoutService.getPanelPosition())) { + return getFloatingHorizontalPanelOuterEdges(layoutService); + } + + const owners = getFloatingOuterEdgeOwners(layoutService); + return { left: owners.left === partId, right: owners.right === partId }; +} + +/** + * Whether a visible horizontal (bottom/top) panel reaches each window edge and should + * therefore receive a doubled outer gutter so it aligns with the editor card above it. + * The panel spans underneath a bar that is not full-height, and reaches an edge whenever + * the bar on that side is hidden or not full-height (and, on the side bar side, the + * activity bar is absent). The full-height/sibling computation mirrors `Layout.adjustPartPositions`. + */ +function getFloatingHorizontalPanelOuterEdges(layoutService: IWorkbenchLayoutService): { left: boolean; right: boolean } { + if (!layoutService.isVisible(Parts.PANEL_PART)) { + return { left: false, right: false }; + } + + const sideBarLeft = layoutService.getSideBarPosition() === Position.LEFT; + const alignment = layoutService.getPanelAlignment(); + + // A bar that is a sibling of the editor is not full-height, so the panel spans + // underneath it to the window edge (panel is horizontal, so `isPanelVertical` is false). + const sideBarSiblingToEditor = !(alignment === 'center' || (sideBarLeft && alignment === 'right') || (!sideBarLeft && alignment === 'left')); + const auxSiblingToEditor = !(alignment === 'center' || (!sideBarLeft && alignment === 'right') || (sideBarLeft && alignment === 'left')); + + const sideBarSideReached = !layoutService.isVisible(Parts.ACTIVITYBAR_PART) && (!layoutService.isVisible(Parts.SIDEBAR_PART) || sideBarSiblingToEditor); + const auxSideReached = !layoutService.isVisible(Parts.AUXILIARYBAR_PART) || auxSiblingToEditor; + + return sideBarLeft + ? { left: sideBarSideReached, right: auxSideReached } + : { left: auxSideReached, right: sideBarSideReached }; +} + const positionsByString: { [key: string]: Position } = { [positionToString(Position.LEFT)]: Position.LEFT, [positionToString(Position.RIGHT)]: Position.RIGHT, From 854f47f16a83371bf268ab7673b06c6e074f3f10 Mon Sep 17 00:00:00 2001 From: Siddharth Singha Roy Date: Tue, 23 Jun 2026 16:14:59 +0000 Subject: [PATCH 041/696] Add chatSessionId to chat.modelChange telemetry --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 6 +++++- .../contrib/chat/browser/widget/input/chatModelPicker.ts | 5 ++++- .../chat/browser/widget/input/modelPickerActionItem.ts | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index d8f0a682a9ea1..4621dda320de1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -91,7 +91,7 @@ import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, IL import { ChatModelConfigurationStore } from './chatModelConfigurationStore.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel, logChangesToStateModel } from '../../../common/model/chatModel.js'; import { filterModelsForSession, findBestMatchingModel, findDefaultModel, hasModelsTargetingSession, isModelValidForSession, mergeModelsWithCache, resolveModelFromSyncState, shouldResetModelToDefault, shouldResetOnModelListChange, shouldRestoreLateArrivingModel, shouldRestorePersistedModel } from './chatModelSelectionLogic.js'; -import { getChatSessionType, LocalChatSessionUri } from '../../../common/model/chatUri.js'; +import { chatSessionResourceToId, getChatSessionType, LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; @@ -1044,6 +1044,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); }, showAutoModel: () => this._showAutoModel(), + getChatSessionId: () => { + const sessionResource = this._widget?.viewModel?.model.sessionResource; + return sessionResource ? chatSessionResourceToId(sessionResource) : undefined; + }, modelConfiguration: this._modelConfigStore, }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 5b99a03dc5b49..bc51f40a99c4d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -154,11 +154,13 @@ type ChatModelChangeClassification = { comment: 'Reporting when the model picker is switched'; fromModel?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous chat model' }; toModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new chat model' }; + chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the current chat session, used to correlate the model switch with the session.' }; }; type ChatModelChangeEvent = { fromModel: string | TelemetryTrustedValue | undefined; toModel: string | TelemetryTrustedValue; + chatSessionId?: string; }; type ChatModelPickerInteraction = 'disabledModelContactAdminClicked' | 'premiumModelUpgradePlanClicked' | 'otherModelsExpanded' | 'otherModelsCollapsed'; @@ -1104,7 +1106,8 @@ export class ModelPickerWidget extends Disposable { const onSelect = (model: ILanguageModelChatMetadataAndIdentifier) => { this._telemetryService.publicLog2('chat.modelChange', { fromModel: previousModel?.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(previousModel.identifier) : 'unknown', - toModel: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(model.identifier) : 'unknown' + toModel: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(model.identifier) : 'unknown', + chatSessionId: this._delegate.getChatSessionId?.() }); this._selectedModel = model; this._renderLabel(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index 44da13283c322..ed8a2795e9f83 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -55,6 +55,13 @@ export interface IModelPickerDelegate { * Student users) instead of an Auto entry. */ showAutoModel?(): boolean; + /** + * The id of the current chat session, used to correlate model-picker + * changes with the session in telemetry. Matches the `chatSessionId` + * reported by other chat telemetry events (e.g. the chat request event). + * Returns `undefined` when no session is active. + */ + getChatSessionId?(): string | undefined; /** * Per-editor model configuration access. When omitted, the picker reads and * writes configuration through the global {@link ILanguageModelsService}. From 5b839e0737d7942511b8de2fb5d5305d519ddc5a Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Tue, 23 Jun 2026 19:40:07 +0200 Subject: [PATCH 042/696] Updates markdown editor (#322577) * updates markdown editor * fixes class names --- .../markdown-editor-src/editor.ts | 4 ++-- extensions/markdown-language-features/package-lock.json | 8 ++++---- extensions/markdown-language-features/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index 83edccd646eab..8f46a419f06d1 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -8,7 +8,7 @@ import { Disposable, autorun } from '@vscode/markdown-editor/observables'; import mermaid from 'mermaid'; import 'katex/dist/katex.min.css'; import '@vscode/markdown-editor/editor.css'; -import '@vscode/markdown-editor/themes/github.css'; +import '@vscode/markdown-editor/themes/vscode.css'; import './markdownEditor.css'; import { WebviewSyntaxHighlighter } from './syntaxHighlighter'; @@ -64,7 +64,7 @@ class Editor extends Disposable { const model = this.model; const view = this._register(new EditorView(model, { - classNames: ['github-markdown-theme'], + classNames: ['md-theme-vscode'], syntaxHighlighter: this.#syntaxHighlighter, onToggleCheckbox: (item, newChecked) => { if (readonly) { diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 38e31e367c5a2..60e0349caf9ed 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-5", + "@vscode/markdown-editor": "^0.0.2-8", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", @@ -620,9 +620,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-5", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-5.tgz", - "integrity": "sha512-v9twExwatxxIrEzE86CscvrsukgW0+qFHISVV4PYLCcBINFDFA7L7ftiKzb1eEkHSR4uUVl8paChC2BellJQow==", + "version": "0.0.2-8", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-8.tgz", + "integrity": "sha512-mmsUYF3ffmunmR9Q9cShVYIUq9orbClFNm9tMNCJ7n1Vh/8EFLn3QJQdoyqeGL53O0gc2VCqPAqwgwOPKadLFA==", "license": "MIT", "dependencies": { "katex": "^0.16.33", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 9be9248806d6a..87ab67029912a 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -898,7 +898,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-5", + "@vscode/markdown-editor": "^0.0.2-8", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", From 914d83636e004c9b668916752b4a2dd8025780f1 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Tue, 23 Jun 2026 10:52:46 -0700 Subject: [PATCH 043/696] Wire up browser tool_instructions for agent host prompts (#322585) * Wire up browser tool_instructions for agent host prompts * Document agent host prompt customization (AGENTS.md) * Simplify tool_instructions API: drop unused replace-mode helper --- .../agentHost/node/copilot/prompts/AGENTS.md | 139 ++++++++++++++++++ .../node/copilot/prompts/promptRegistry.ts | 6 +- .../node/copilot/prompts/toolInstructions.ts | 61 ++++---- .../test/node/agentHostPromptRegistry.test.ts | 42 +++++- .../test/node/toolInstructions.test.ts | 70 +++++---- .../common/browserChatToolReferenceNames.ts | 4 + .../tools/clickBrowserTool.ts | 2 +- .../electron-browser/tools/dragElementTool.ts | 2 +- .../tools/handleDialogBrowserTool.ts | 2 +- .../tools/hoverElementTool.ts | 2 +- .../tools/navigateBrowserTool.ts | 2 +- .../electron-browser/tools/openBrowserTool.ts | 2 +- .../electron-browser/tools/readBrowserTool.ts | 2 +- .../tools/runPlaywrightCodeTool.ts | 2 +- .../tools/screenshotBrowserTool.ts | 2 +- .../electron-browser/tools/typeBrowserTool.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 2 +- 17 files changed, 259 insertions(+), 85 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md rename src/vs/{workbench/contrib => platform}/browserView/common/browserChatToolReferenceNames.ts (79%) diff --git a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md new file mode 100644 index 0000000000000..d503c7922a5cd --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md @@ -0,0 +1,139 @@ +# Agent host system-prompt customization + +This directory customizes the system prompt for Copilot CLI **agent host** +(ahp+cli) sessions. Read this before changing how the system message is built or +adding per-model / per-tool guidance. It mirrors the Copilot extension's +`extensions/copilot/.../prompts/node/agent/` (agentPrompts), but the agent host +runs in its own process and cannot use prompt-tsx, so contributors return plain +data the SDK accepts directly. + +## Files + +- `promptRegistry.ts` — `AgentHostPromptRegistry`: resolves the final + `SystemMessageConfig` for a session's model. Defines the `IAgentHostPrompt` + contributor interface and the `IAgentHostPromptContext` read-time context. +- `systemMessage.ts` — the default message (`COPILOT_AGENT_HOST_SYSTEM_MESSAGE`), + shared identity text, the `fullSystemPrompt` / `sectionOverrides` builders, and + `describeSystemMessageConfig` (the one-line log summary). +- `toolInstructions.ts` — the model-agnostic `tool_instructions` layer: gated + one-line nudges (`TOOL_INSTRUCTION_LINES`) composed into the SDK's + `tool_instructions` section. The browser line is the one registered today. +- `anthropicPrompt.ts` — example per-model contributor (Claude Opus 4.8). +- `allPrompts.ts` — side-effect import hub; importing it registers every + contributor into the shared `agentHostPromptRegistry`. + +## How the system message is built + +`resolveSystemMessageConfig(model, context)` runs two steps: + +1. **`_resolveModelConfig`** — picks the per-model (or default) config. Falls + back to `COPILOT_AGENT_HOST_SYSTEM_MESSAGE` when there's no model, no matching + contributor, or the contributor opts out for this `context`. +2. **`_withUniversalSections`** — layers the model-agnostic sections (currently + just `tool_instructions`) on top, **composing** with — never clobbering — any + per-model override for that section. + +> **Launch-time freeze.** The SDK accepts a system message only at session +> create/resume; there is no mid-session update. The prompt is resolved once per +> (re)launch and any tool-gated content reflects the tool set at that moment. A +> change to the session's tools/plugins is part of the launcher's restart +> snapshot, so it re-launches and recomputes; an in-flight turn keeps the prompt +> it launched with. + +There are two ways to customize, and a model can use both at once. + +## Lever 1 — universal, all models (`toolInstructions.ts`) + +Guidance for a tool that should apply to **every** model whenever that tool is in +the session. This is what the browser line does. + +1. Write a `ToolInstructionLine` — a function `(hasTool) => string | undefined` + that returns one sentence (no surrounding newlines) when its tool is present, + or `undefined` to contribute nothing. +2. Add it to `TOOL_INSTRUCTION_LINES`. + +```ts +const exampleToolInstructions: ToolInstructionLine = hasTool => + hasTool('someClientToolReferenceName') + ? 'One sentence of guidance, shown only when that tool is present.' + : undefined; + +const TOOL_INSTRUCTION_LINES: readonly ToolInstructionLine[] = [browserToolInstructions, exampleToolInstructions]; +``` + +**Caveat — `hasTool` sees CLIENT tools only.** It is `context.hasClientTool`, +which knows only the forwarded workbench tools, addressed by their **camelCase +`toolReferenceName`** (e.g. `openBrowserPage`, `runTask`, `getTaskOutput`) — NOT +the extension's snake_case ids, and NOT shell / server-SDK / MCP tools (MCP is +discovered dynamically and isn't in the launch snapshot). A +line gated on a name that is never a client tool silently never renders. The +default client-tool allowlist is `chat.agentHost.clientTools` (see +`chat.shared.contribution.ts`). Broadening this context is a known follow-up. + +These lines compose with a per-model `tool_instructions` override (see +`composeToolInstructions`), so Lever 1 and Lever 2 stack. + +## Lever 2 — per-model contributor (`promptRegistry.ts` + `allPrompts.ts`) + +Guidance scoped to a model or family. Implement `IAgentHostPrompt` and register +it. Use `anthropicPrompt.ts` as the template. + +A contributor provides EITHER: + +- `resolveSectionOverrides` → `{ mode: 'customize' }` — overrides named sections, + keeps the SDK foundation prompt and its guardrails. **Prefer this.** +- `resolveFullSystemPrompt` → `{ mode: 'replace' }` — owns the entire prompt and + **drops all SDK guardrails (including safety)**. Only for callers that truly + own the whole prompt. A replace contributor bypasses Lever 1, so it must inline + any universal guidance itself (`universalToolInstructions(hasTool)` renders the + same gated lines; add a small replace-mode helper alongside it when the first + such contributor lands). + +```ts +class MyModelPrompt implements IAgentHostPrompt { + static readonly familyPrefixes = ['my-model']; // or implement static matchesModel(model) + resolveSectionOverrides(model: ModelSelection, context: IAgentHostPromptContext) { + // Gate on host settings; return undefined to fall back to the default message. + return context.getSetting(AgentHostConfigKey.SomeFlag) === true + ? { tool_instructions: { action: 'append', content: '\nFor this model, batch independent tool calls.' } } + : undefined; + } +} +agentHostPromptRegistry.registerPrompt(MyModelPrompt); // then add `import './myModelPrompt.js'` to allPrompts.ts +``` + +Matching: a contributor matches a model by `static matchesModel(model)` (takes +precedence) or by `familyPrefixes` (model-id `startsWith`). The registry resolves +**exactly one** contributor per model (first match wins) — base + version +layering is a known follow-up. + +## Reference + +- **Modes** (`SystemMessageConfig.mode`): `append` (foundation + text, default), + `customize` (override named sections), `replace` (own the whole prompt, no + guardrails). +- **Sections** (`SystemMessageSection`): `identity`, `tone`, `tool_efficiency`, + `environment_context`, `code_change_rules`, `guidelines`, `safety`, + `tool_instructions`, `custom_instructions`, `runtime_instructions`, + `last_instructions`. +- **Override actions** (`SectionOverride.action`): `replace`, `append`, + `prepend`, `remove`, or a `(content: string) => string` transform. + +## Gotchas + +- **Empty overrides = no override.** `resolveSectionOverrides` returning `{}` + (or `undefined`) falls back to the default message rather than emitting an + empty customize config that would drop the default identity. +- **Don't mutate the shared default.** `COPILOT_AGENT_HOST_SYSTEM_MESSAGE` is a + shared constant; layering spreads into a fresh object, preserving any other + customize-mode fields (e.g. `content`). Keep it that way. +- **Spacing is relative to the foundation.** `composeToolInstructions` pads by + action (`append` leads with `\n`, `prepend` trails with `\n`, `replace` owns + the section). When writing a section's `content` by hand, a leading `\n` keeps + appended text off the foundation's last line. +- **Observability.** The launcher logs `describeSystemMessageConfig(...)` at + `info` (mode + overridden sections) and the full config at `trace`. Keep new + config shapes summarizable there. +- **Tests.** `../../../test/node/agentHostPromptRegistry.test.ts` covers the + registry/wiring; `../../../test/node/toolInstructions.test.ts` covers the + composition/gating. Add cases there, not new harnesses. diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts index 60bc1d2117bab..321a8c052b375 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts @@ -183,7 +183,7 @@ export class AgentHostPromptRegistry { * for a contributor's full `replace` prompt (which owns the entire system * message and intentionally drops the SDK foundation) and for `append` mode. * A `replace` contributor that wants the universal guidance re-includes it - * itself by calling `appendUniversalToolInstructions` (in `toolInstructions.ts`) + * itself by rendering `universalToolInstructions` (in `toolInstructions.ts`) * from its `resolveFullSystemPrompt`, mirroring how the extension's full-prompt * models inline the same lines. * @@ -198,9 +198,7 @@ export class AgentHostPromptRegistry { if (!toolInstructions) { return config; } - // Spread into a fresh object so the shared `COPILOT_AGENT_HOST_SYSTEM_MESSAGE` - // constant (returned by the fallback paths above) is never mutated. - return sectionOverrides({ ...config.sections, tool_instructions: toolInstructions }); + return { ...config, sections: { ...config.sections, tool_instructions: toolInstructions } }; } } diff --git a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts index 957de4fcb0cdd..c32495471cea5 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts @@ -5,6 +5,7 @@ import type { SectionOverride } from '@github/copilot-sdk'; import { coalesce } from '../../../../../base/common/arrays.js'; +import { BrowserChatToolReferenceName, browserChatToolReferenceNames } from '../../../../browserView/common/browserChatToolReferenceNames.js'; /** * Model-agnostic guidance for the `tool_instructions` system-prompt section. @@ -18,11 +19,10 @@ import { coalesce } from '../../../../../base/common/arrays.js'; * snake_case ids). * * To add guidance for a tool, write a {@link ToolInstructionLine} and add it to - * {@link TOOL_INSTRUCTION_LINES}. For example, a browser line would gate on - * `openBrowserPage` (plus an agentic browser tool) being present and return a - * sentence such as "Use the browser tools (...) for front-end tasks". No lines - * are registered yet — concrete tool hookups land in follow-up changes; this - * module is the wiring they plug into. + * {@link TOOL_INSTRUCTION_LINES}. The browser guidance ({@link browserToolInstructions}) + * is the first such hookup: it gates on `openBrowserPage` (plus an agentic browser + * tool) being present and returns the extension's "Use the browser tools (...)" + * sentence. */ /** @@ -36,10 +36,34 @@ import { coalesce } from '../../../../../base/common/arrays.js'; type ToolInstructionLine = (hasTool: (name: string) => boolean) => string | undefined; /** - * The registered tool-instruction lines, in render order. Empty until concrete - * tool hookups are added — add new per-tool guidance here. + * Browser tools other than `openBrowserPage` — the agent-host equivalent of the + * Copilot extension's `agenticBrowserTools`. Derived from the full reference-name + * list so it stays in sync as browser tools are added or removed. */ -const TOOL_INSTRUCTION_LINES: readonly ToolInstructionLine[] = []; +const agenticBrowserToolNames = browserChatToolReferenceNames.filter(name => name !== BrowserChatToolReferenceName.OpenBrowserPage); + +/** + * Front-end guidance for the integrated browser tools, ported from the Copilot + * extension's `defaultAgentInstructions`/per-model prompts. Emitted only when the + * page-opening tool AND at least one agentic browser tool are available, naming + * the first available agentic tool as the example (the rest are covered by "etc."). + */ +const browserToolInstructions: ToolInstructionLine = hasTool => { + if (!hasTool(BrowserChatToolReferenceName.OpenBrowserPage)) { + return undefined; + } + const companion = agenticBrowserToolNames.find(hasTool); + if (!companion) { + return undefined; + } + return `Use the browser tools (${BrowserChatToolReferenceName.OpenBrowserPage}, ${companion}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.`; +}; + +/** + * The registered tool-instruction lines, in render order. Add new per-tool + * guidance here. + */ +const TOOL_INSTRUCTION_LINES: readonly ToolInstructionLine[] = [browserToolInstructions]; /** * Composes the applicable `lines` into a single block (one line each), or @@ -60,7 +84,7 @@ export function universalToolInstructions(hasTool: (name: string) => boolean, li * * @param existing the per-model contributor's `tool_instructions` override, if any. */ -export function composeToolInstructions(existing: SectionOverride | undefined, content: string): SectionOverride { +function composeToolInstructions(existing: SectionOverride | undefined, content: string): SectionOverride { // No per-model override: append after the SDK foundation section, led by a // newline so it doesn't run on from the foundation content. if (!existing) { @@ -100,22 +124,3 @@ export function resolveToolInstructionsOverride(hasTool: (name: string) => boole const content = universalToolInstructions(hasTool, lines); return content === undefined ? undefined : composeToolInstructions(existing, content); } - -/** - * Appends the universal tool-instruction lines to a full (`replace`-mode) - * system prompt's `content`. - * - * A `replace`-mode contributor owns its whole prompt and so is skipped by the - * registry's universal `tool_instructions` layer. When such a contributor is - * added, calling this from its `resolveFullSystemPrompt` re-includes the same - * gated guidance (separated by a blank line), mirroring how the extension's - * full-prompt models inline it. No `resolveFullSystemPrompt` contributor exists - * yet, so this currently has no production caller. Returns `content` unchanged - * when no line applies. - * - * @param lines defaults to the registered {@link TOOL_INSTRUCTION_LINES}. - */ -export function appendUniversalToolInstructions(content: string, hasTool: (name: string) => boolean, lines: readonly ToolInstructionLine[] = TOOL_INSTRUCTION_LINES): string { - const extra = universalToolInstructions(hasTool, lines); - return extra === undefined ? content : `${content}\n\n${extra}`; -} diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index 0f1c3c1168c8d..554800a8bd96e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -10,6 +10,7 @@ import type { SchemaValues } from '../../common/agentHostSchema.js'; import type { ModelSelection } from '../../common/state/protocol/state.js'; import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +import { BrowserChatToolReferenceName } from '../../../browserView/common/browserChatToolReferenceNames.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../node/copilot/prompts/allPrompts.js'; @@ -131,17 +132,46 @@ suite('AgentHostPromptRegistry', () => { }); suite('universal tool instructions wiring', () => { - // No tool-instruction lines are registered yet (concrete tool hookups land - // in follow-up changes), so the universal layer is currently a no-op. These - // guard the wiring; the composition/gating itself is covered in - // toolInstructions.test.ts. + // The browser line is the registered universal tool-instruction (see + // toolInstructions.ts). These guard that the registry layers it end-to-end; + // the composition/gating itself is covered in toolInstructions.test.ts. + const BROWSER_LINE = 'Use the browser tools (openBrowserPage, readPage, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.'; + const browserTools = [BrowserChatToolReferenceName.OpenBrowserPage, BrowserChatToolReferenceName.ReadPage]; - test('is a no-op while no tool-instruction lines are registered', () => { + test('is a no-op when the session exposes no matching tools', () => { const registry = new AgentHostPromptRegistry(); assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); }); - test('leaves a per-model tool_instructions override untouched', () => { + test('layers the browser tool_instructions onto the default config when browser tools are present', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'm' }, context({}, browserTools)), + { + mode: 'customize', + sections: { + identity: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections.identity, + tool_instructions: { action: 'append', content: `\n${BROWSER_LINE}` }, + }, + } + ); + }); + + test('composes the browser line with a per-model tool_instructions override', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['claude']; + resolveSectionOverrides(): Partial> { + return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, browserTools)), + { mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${BROWSER_LINE}` } } } + ); + }); + + test('leaves a per-model tool_instructions override untouched when no browser tools are present', () => { const registry = new AgentHostPromptRegistry(); registry.registerPrompt(class { static readonly familyPrefixes = ['claude']; diff --git a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts index 612b56d74769d..18c6ff025a806 100644 --- a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts +++ b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import type { SectionOverride } from '@github/copilot-sdk'; -import { appendUniversalToolInstructions, composeToolInstructions, resolveToolInstructionsOverride, universalToolInstructions } from '../../node/copilot/prompts/toolInstructions.js'; +import { resolveToolInstructionsOverride, universalToolInstructions } from '../../node/copilot/prompts/toolInstructions.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; /** Builds a `hasTool` predicate backed by the given available tool names. */ @@ -28,15 +28,37 @@ suite('toolInstructions', () => { assert.strictEqual(universalToolInstructions(hasTools('a', 'c'), [lineFor('a'), lineFor('b'), lineFor('c')]), 'use a\nuse c'); }); - test('returns undefined when no line applies (including the empty registry)', () => { + test('returns undefined when no line applies (including the default registry when its lines do not apply)', () => { assert.strictEqual(universalToolInstructions(hasTools('x'), [lineFor('a')]), undefined); assert.strictEqual(universalToolInstructions(hasTools('a')), undefined); }); + + test('renders the registered browser line from the default registry only when openBrowserPage + an agentic browser tool are present', () => { + assert.deepStrictEqual( + [ + universalToolInstructions(hasTools('openBrowserPage', 'readPage')), + universalToolInstructions(hasTools('openBrowserPage')), + universalToolInstructions(hasTools('readPage')), + ], + [ + 'Use the browser tools (openBrowserPage, readPage, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.', + undefined, + undefined, + ] + ); + }); }); - suite('composeToolInstructions', () => { - test('appends to the foundation section when there is no per-model override', () => { - assert.deepStrictEqual(composeToolInstructions(undefined, 'LINE'), { action: 'append', content: '\nLINE' }); + // `composeToolInstructions` is module-private; its composition/spacing + // behavior is exercised here through the public `resolveToolInstructionsOverride` + // (injecting synthetic lines via the `lines` seam). + suite('resolveToolInstructionsOverride', () => { + test('returns undefined (keep existing) when no line applies', () => { + assert.strictEqual(resolveToolInstructionsOverride(hasTools('x'), { action: 'append', content: 'A' }, [lineFor('a')]), undefined); + }); + + test('with no per-model override, appends the rendered line after the foundation section', () => { + assert.deepStrictEqual(resolveToolInstructionsOverride(hasTools('a'), undefined, [lineFor('a')]), { action: 'append', content: '\nuse a' }); }); test('folds into a per-model string override, preserving action and foundation spacing', () => { @@ -46,42 +68,18 @@ suite('toolInstructions', () => { { action: 'replace', content: 'OWN' },// owns the section → no padding { action: 'replace', content: '' }, // empty replace → no spurious leading newline ]; - assert.deepStrictEqual(overrides.map(o => composeToolInstructions(o, 'LINE')), [ - { action: 'append', content: '\nA\nLINE' }, - { action: 'prepend', content: 'P\nLINE\n' }, - { action: 'replace', content: 'OWN\nLINE' }, - { action: 'replace', content: 'LINE' }, + assert.deepStrictEqual(overrides.map(o => resolveToolInstructionsOverride(hasTools('a'), o, [lineFor('a')])), [ + { action: 'append', content: '\nA\nuse a' }, + { action: 'prepend', content: 'P\nuse a\n' }, + { action: 'replace', content: 'OWN\nuse a' }, + { action: 'replace', content: 'use a' }, ]); }); test('preserves a remove or transform-function override untouched', () => { const transform = (s: string) => s; - assert.deepStrictEqual(composeToolInstructions({ action: 'remove' }, 'LINE'), { action: 'remove' }); - assert.deepStrictEqual(composeToolInstructions({ action: transform }, 'LINE'), { action: transform }); - }); - }); - - suite('resolveToolInstructionsOverride', () => { - test('returns undefined (keep existing) when no line applies', () => { - const existing: SectionOverride = { action: 'append', content: 'A' }; - assert.strictEqual(resolveToolInstructionsOverride(hasTools('x'), existing, [lineFor('a')]), undefined); - }); - - test('composes the rendered lines with the existing override', () => { - assert.deepStrictEqual( - resolveToolInstructionsOverride(hasTools('a'), { action: 'append', content: 'A' }, [lineFor('a')]), - { action: 'append', content: '\nA\nuse a' } - ); - }); - }); - - suite('appendUniversalToolInstructions', () => { - test('returns the prompt unchanged while no lines apply (empty registry)', () => { - assert.strictEqual(appendUniversalToolInstructions('FULL PROMPT', hasTools('a')), 'FULL PROMPT'); - }); - - test('appends the rendered lines after a blank line when a line applies', () => { - assert.strictEqual(appendUniversalToolInstructions('FULL PROMPT', hasTools('a'), [lineFor('a')]), 'FULL PROMPT\n\nuse a'); + assert.deepStrictEqual(resolveToolInstructionsOverride(hasTools('a'), { action: 'remove' }, [lineFor('a')]), { action: 'remove' }); + assert.deepStrictEqual(resolveToolInstructionsOverride(hasTools('a'), { action: transform }, [lineFor('a')]), { action: transform }); }); }); }); diff --git a/src/vs/workbench/contrib/browserView/common/browserChatToolReferenceNames.ts b/src/vs/platform/browserView/common/browserChatToolReferenceNames.ts similarity index 79% rename from src/vs/workbench/contrib/browserView/common/browserChatToolReferenceNames.ts rename to src/vs/platform/browserView/common/browserChatToolReferenceNames.ts index ec7138fe37c88..da7e826c04ebb 100644 --- a/src/vs/workbench/contrib/browserView/common/browserChatToolReferenceNames.ts +++ b/src/vs/platform/browserView/common/browserChatToolReferenceNames.ts @@ -5,6 +5,10 @@ /** * Tool reference names for the integrated browser tools. + * + * Lives in `platform` (rather than next to the `workbench/contrib/browserView` + * tools that produce them) so the Copilot agent host — which cannot import from + * `workbench` — can gate its browser tool instructions on the same names. */ export const BrowserChatToolReferenceName = { OpenBrowserPage: 'openBrowserPage', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts index 5d4683ed864cc..72b69a1d84a81 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const ClickBrowserToolData: IToolData = { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts index 9c887d1b97905..df06ad9774bec 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const DragElementToolData: IToolData = { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts index ce43e5d282049..72d6e2a2950a4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { createBrowserPageLink, errorResult, getSessionId } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const HandleDialogBrowserToolData: IToolData = { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts index e3f037bd945f3..9faef8c648cab 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const HoverElementToolData: IToolData = { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts index 751041a9a64b7..c783e9c157333 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts @@ -12,7 +12,7 @@ import { IPlaywrightService } from '../../../../../platform/browserView/common/p import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; import { createBrowserPageLink, errorResult, getSessionId, playwrightInvoke, remoteUrlRewriteNotice, rewriteRemoteLocalhostUrl } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { IRemoteExplorerService } from '../../../../services/remote/common/remoteExplorerService.js'; import { OpenPageToolId } from './openBrowserTool.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 323ddbb2447c7..10b525fa084c5 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -23,7 +23,7 @@ import { IChatRequestModel } from '../../../chat/common/model/chatModel.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult, getSessionId, remoteUrlRewriteNotice, rewriteRemoteLocalhostUrl } from './browserToolHelpers.js'; import { IRemoteExplorerService } from '../../../../services/remote/common/remoteExplorerService.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts index 3b574b4fc6982..9439515b1909e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { createBrowserPageLink, errorResult, getSessionId } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const ReadBrowserToolData: IToolData = { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts index d30bc0e9f24b7..3df5dd1a69f78 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { errorResult, getSessionId, invokeFunctionResultToToolResult } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const RunPlaywrightCodeToolData: IToolData = { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index 6f76382eab66f..41182cd1f9eff 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -21,7 +21,7 @@ import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { errorResult, getSessionId, playwrightInvokeRaw } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; import { ReadBrowserToolData } from './readBrowserTool.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts index 4b68cf2130bd7..43eae0e073059 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts @@ -13,7 +13,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { createBrowserPageLink, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; -import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; +import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const TypeBrowserToolData: IToolData = { 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 6ce28a2cb0fba..17d8654234c67 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -208,7 +208,7 @@ import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; import { UtilityModelContribution, UtilitySmallModelContribution } from './utilityModelContribution.js'; import { ChatImageCarouselService, IChatImageCarouselService } from './chatImageCarouselService.js'; -import { browserChatToolReferenceNames } from '../../browserView/common/browserChatToolReferenceNames.js'; +import { browserChatToolReferenceNames } from '../../../../platform/browserView/common/browserChatToolReferenceNames.js'; CommandsRegistry.registerCommand('_chat.notifyQuestionCarouselAnswer', (accessor: ServicesAccessor, resolveId: string, answers?: import('../common/chatService/chatService.js').IChatQuestionAnswers) => { accessor.get(IChatService).notifyQuestionCarouselAnswer('', resolveId, answers); From 309b5b5a7010ef6d666336245751abf2d45c21fe Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 23 Jun 2026 10:58:57 -0700 Subject: [PATCH 044/696] propagate model list to the agent host --- .../agentHost/common/agentHostByokLm.ts | 23 +++++++ .../common/agentHostClientByokLmChannel.ts | 6 ++ .../platform/agentHost/node/agentHostMain.ts | 26 ++++---- .../node/copilot/copilotSessionLauncher.ts | 62 ++++++++++++++++++- .../node/agentHostClientByokLmChannel.test.ts | 15 ++++- .../test/node/byokLmProxyService.test.ts | 4 +- .../agentHost/agentHostByokLmHandler.ts | 19 ++++++ .../agentHostByokLmHandler.test.ts | 18 ++++++ 8 files changed, 155 insertions(+), 18 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts index c454a562e813a..e8fb506dcc3e0 100644 --- a/src/vs/platform/agentHost/common/agentHostByokLm.ts +++ b/src/vs/platform/agentHost/common/agentHostByokLm.ts @@ -75,6 +75,21 @@ export interface IByokLmChatResult { readonly error?: string; } +/** + * Metadata for a renderer BYOK model, enumerated over the bridge so the node + * agent host can advertise it to the SDK runtime without any host-side config. + */ +export interface IByokLmModelInfo { + /** Provider/vendor name (the LM API vendor that registered the model). */ + readonly vendor: string; + /** Provider-local model id. */ + readonly id: string; + /** Display name, when the provider supplies one. */ + readonly name?: string; + /** Maximum context window tokens, when known. */ + readonly maxContextWindowTokens?: number; +} + export const IAgentHostByokLmHandler = createDecorator('agentHostByokLmHandler'); /** @@ -92,6 +107,13 @@ export interface IAgentHostByokLmHandler { * {@link IByokLmChatResult.error}) when no such model is available. */ chat(request: IByokLmChatRequest, token: CancellationToken): Promise; + + /** + * Enumerate the renderer's BYOK models (vendor `isBYOK`, excluding + * session-scoped agent-host copies) so the node agent host can synthesize + * provider/model config for the SDK runtime. + */ + listModels(token: CancellationToken): Promise; } /** @@ -100,4 +122,5 @@ export interface IAgentHostByokLmHandler { */ export interface IByokLmBridgeConnection { chat(request: IByokLmChatRequest): Promise; + listModels(): Promise; } diff --git a/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts index 49f2ebca10af2..e029f48af559b 100644 --- a/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts +++ b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts @@ -11,6 +11,7 @@ import { IByokLmBridgeConnection, IByokLmChatRequest, IByokLmChatResult, + IByokLmModelInfo, } from './agentHostByokLm.js'; /** @@ -34,6 +35,7 @@ export const AGENT_HOST_CLIENT_BYOK_LM_CHANNEL = 'agentHostClientByokLm'; export function createAgentHostClientByokLmConnection(channel: IChannel): IByokLmBridgeConnection { return { chat: (request) => channel.call('chat', request) as Promise, + listModels: () => channel.call('listModels') as Promise, }; } @@ -58,6 +60,10 @@ export class AgentHostClientByokLmChannel implements IServerChannel { const result = await this._handler.chat(arg as IByokLmChatRequest, CancellationToken.None); return result as T; } + case 'listModels': { + const models = await this._handler.listModels(CancellationToken.None); + return models as T; + } } throw new Error(`Unknown command '${command}' on AgentHostClientByokLmChannel`); } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index ab34263cf93fc..fec21d60457b8 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -179,16 +179,15 @@ async function startAgentHost(): Promise { diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); diServices.set(ICodexProxyService, codexProxyService); - // BYOK language-model proxy + bridge registry, gated behind - // `chat.agentHost.byokModels.enabled`. The registry is populated per - // renderer connection below; the proxy lazily binds when the session - // launcher starts it for a session that selected a forwarded BYOK model. - if (byokLmEnabled) { - const byokLmBridgeRegistry = new ByokLmBridgeRegistry(); - diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); - const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); - diServices.set(IByokLmProxyService, byokLmProxyService); - } + // BYOK language-model proxy + bridge registry. Always registered so the + // session launcher can inject them, but BYOK *use* is gated: the + // per-connection bridge below (and the renderer's server channel) are only + // wired when `chat.agentHost.byokModels.enabled` is on, so the registry + // stays empty and the proxy never binds when the feature is off. + const byokLmBridgeRegistry = new ByokLmBridgeRegistry(); + diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); + const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); + diServices.set(IByokLmProxyService, byokLmProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); @@ -235,7 +234,7 @@ async function startAgentHost(): Promise { // in-process renderer-to-utility-process MessagePort transport). const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); - const byokLmBridgeRegistry = byokLmEnabled ? instantiationService.invokeFunction(accessor => accessor.get(IByokLmBridgeRegistry)) : undefined; + const byokLmBridgeRegistry = instantiationService.invokeFunction(accessor => accessor.get(IByokLmBridgeRegistry)); // Wire reverse-RPC for in-process renderer connections. The renderer's // `MessagePortClient` ctx is its `clientId`, and it exposes the @@ -255,7 +254,10 @@ async function startAgentHost(): Promise { clientId, channelName => server.getChannel(channelName, c => c.ctx === clientId), clientFileSystemProvider, - byokLmBridgeRegistry, + // BYOK bridge is gated: only wire it when the feature is enabled, so + // the registry stays empty (and the launcher synthesizes no BYOK + // providers/models) when `chat.agentHost.byokModels.enabled` is off. + byokLmEnabled ? byokLmBridgeRegistry : undefined, )); }; disposables.add(server.onDidAddConnection(registerConnection)); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index a28716b1e4e99..4ca27076e1ac7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, ExitPlanModeRequest, ExitPlanModeResult, PermissionRequestResult, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; +import type { CopilotClient, ExitPlanModeRequest, ExitPlanModeResult, NamedProviderConfig, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; @@ -14,6 +14,9 @@ import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHos import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyService.js'; +import type { IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; import type { ActiveClientState } from '../activeClientState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -195,11 +198,15 @@ export function getCopilotContextTier(model: ModelSelection | undefined): Sessio export class CopilotSessionLauncher implements ICopilotSessionLauncher { + private _byokProxyHandle: Promise | undefined; + constructor( @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, @ILogService private readonly _logService: ILogService, @IFileService private readonly _fileService: IFileService, + @IByokLmProxyService private readonly _byokLmProxyService: IByokLmProxyService, + @IByokLmBridgeRegistry private readonly _byokLmBridgeRegistry: IByokLmBridgeRegistry, ) { } async launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { @@ -298,8 +305,60 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } } + /** + * Synthesize SDK provider/model config for the renderer's BYOK models so the + * runtime issues their inference against the node-side loopback proxy (which + * bridges back to the renderer LM API). Returns empty when BYOK is gated off + * (no active bridge), the renderer reports no BYOK models, or enumeration + * fails — in each case the session simply launches without BYOK models. + * + * Each provider points at `proxy.providerBaseUrl(vendor)` and authenticates + * with the session-scoped `Bearer .`; each model surfaces + * under the provider-qualified selection id `vendor/id`, matching what the + * renderer's `AgentHostByokLmHandler` resolves. + */ + private async _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { + const connection = this._byokLmBridgeRegistry.getActive(); + if (!connection) { + return {}; + } + let byokModels: IByokLmModelInfo[]; + try { + byokModels = await connection.listModels(); + } catch (err) { + this._logService.warn(`[Copilot:${sessionId}] Failed to enumerate BYOK models from renderer bridge`, err); + return {}; + } + if (byokModels.length === 0) { + return {}; + } + if (!this._byokProxyHandle) { + this._byokProxyHandle = this._byokLmProxyService.start(); + } + const handle = await this._byokProxyHandle; + const providers: NamedProviderConfig[] = [...new Set(byokModels.map(m => m.vendor))].map(vendor => ({ + name: vendor, + type: 'openai', + wireApi: 'completions', + baseUrl: handle.providerBaseUrl(vendor), + bearerToken: `${handle.nonce}.${sessionId}`, + })); + const models: ProviderModelConfig[] = byokModels.map(m => ({ + id: m.id, + provider: m.vendor, + ...(m.name !== undefined ? { name: m.name } : {}), + ...(m.maxContextWindowTokens !== undefined ? { maxContextWindowTokens: m.maxContextWindowTokens } : {}), + })); + this._logService.info(`[Copilot:${sessionId}] Wired ${models.length} BYOK model(s) across ${providers.length} provider(s) via loopback proxy ${handle.baseUrl}`); + return { providers, models }; + } + private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { const plugins = plan.snapshot.plugins; + // Synthesize BYOK provider/model config (empty when BYOK is gated off or the + // renderer reports no BYOK models). Merged into the returned config so both + // createSession and resumeSession advertise the models to the runtime. + const byok = await this._resolveByokSessionConfig(plan.sessionId); const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited> = []; if (enableCustomTerminalTool) { @@ -335,6 +394,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.trace(`[Copilot:${plan.sessionId}] System message config: ${JSON.stringify(systemMessage, (_key, value) => typeof value === 'function' ? '[transform fn]' : value)}`); } return { + ...byok, clientName: 'vscode', enableMcpApps: true, onPermissionRequest: request => runtime.handlePermissionRequest(request), diff --git a/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts index 732798c5d450d..0912e644c04a6 100644 --- a/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts @@ -7,15 +7,18 @@ import assert from 'assert'; import { Event } from '../../../../base/common/event.js'; import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import type { IAgentHostByokLmHandler, IByokLmChatRequest, IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import type { IAgentHostByokLmHandler, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { AgentHostClientByokLmChannel, createAgentHostClientByokLmConnection } from '../../common/agentHostClientByokLmChannel.js'; suite('agentHostClientByokLmChannel', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function handlerOf(impl: (request: IByokLmChatRequest) => Promise): IAgentHostByokLmHandler { - return { _serviceBrand: undefined, chat: (request) => impl(request) }; + function handlerOf( + chat: (request: IByokLmChatRequest) => Promise, + listModels: () => Promise = async () => [], + ): IAgentHostByokLmHandler { + return { _serviceBrand: undefined, chat: (request) => chat(request), listModels: () => listModels() }; } /** @@ -56,6 +59,12 @@ suite('agentHostClientByokLmChannel', () => { assert.strictEqual(result.error, 'no model'); }); + test('round-trips listModels to the handler', async () => { + const models: IByokLmModelInfo[] = [{ vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 128000 }]; + const connection = bridge(handlerOf(async () => ({ content: '' }), async () => models)); + assert.deepStrictEqual(await connection.listModels(), models); + }); + test('rejects unknown channel commands', async () => { const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' }))); await assert.rejects(() => server.call(null, 'frobnicate'), /Unknown command/); diff --git a/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts index da608108d5cdf..02201decead11 100644 --- a/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts @@ -29,7 +29,7 @@ suite('ByokLmProxyService', () => { run: (handle: IByokLmProxyHandle) => Promise, ): Promise { const registry = new ByokLmBridgeRegistry(); - const registration = registry.register('client-1', { chat }); + const registration = registry.register('client-1', { chat, listModels: async () => [] }); const service = new ByokLmProxyService(new NullLogService(), registry); const handle = await service.start(); try { @@ -226,7 +226,7 @@ suite('ByokLmProxyService', () => { test('rebinds with a fresh nonce after every handle is disposed', async () => { const registry = new ByokLmBridgeRegistry(); - const registration = registry.register('client-1', { chat: async () => ({ content: 'ok' }) }); + const registration = registry.register('client-1', { chat: async () => ({ content: 'ok' }), listModels: async () => [] }); const service = new ByokLmProxyService(new NullLogService(), registry); const first = await service.start(); const firstNonce = first.nonce; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts index df95d758ba7c0..4a28fb1828fe1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts @@ -10,6 +10,7 @@ import { IByokLmChatMessage, IByokLmChatRequest, IByokLmChatResult, + IByokLmModelInfo, IByokLmToolCall, } from '../../../../../../platform/agentHost/common/agentHostByokLm.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -90,6 +91,24 @@ export class AgentHostByokLmHandler extends Disposable implements IAgentHostByok } } + async listModels(_token: CancellationToken): Promise { + const models: IByokLmModelInfo[] = []; + for (const identifier of this._languageModelsService.getLanguageModelIds()) { + const metadata = this._languageModelsService.lookupLanguageModel(identifier); + // Only genuine renderer BYOK models — exclude agent-host copies, which + // carry a `targetChatSessionType` and would otherwise re-enter the bridge. + if (metadata?.isBYOK && !metadata.targetChatSessionType) { + models.push({ + vendor: metadata.vendor, + id: metadata.id, + name: metadata.name, + maxContextWindowTokens: metadata.maxInputTokens, + }); + } + } + return models; + } + /** * Find the LM API identifier for a BYOK model addressed by its vendor and * provider-local id (the `provider/id` selection id the picker surfaced). diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts index a16a68bac67f7..9a27f6d5c61f8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts @@ -84,6 +84,24 @@ suite('AgentHostByokLmHandler', () => { return store.add(new AgentHostByokLmHandler(service, new NullLogService())); } + test('listModels enumerates renderer BYOK models and excludes agent-host copies', async () => { + const service = new TestLanguageModelsService( + new Map([ + ['id-acme', byokModel('acme', 'claude')], + ['id-copy', { ...byokModel('acme', 'claude'), targetChatSessionType: 'copilotcli' }], + ['id-capi', { ...byokModel('copilot', 'gpt-4'), isBYOK: false }], + ]), + () => responseOf([]), + ); + const handler = createHandler(service); + + const models = await handler.listModels(CancellationToken.None); + + assert.deepStrictEqual(models, [ + { vendor: 'acme', id: 'claude', name: 'acme claude', maxContextWindowTokens: 1000 }, + ]); + }); + test('resolves the BYOK model and buffers text + tool calls', async () => { const service = new TestLanguageModelsService( new Map([['id-acme-claude', byokModel('acme', 'claude')]]), From aaf5994bbcda31d5f7ac57b81e7102b61fd5d4fa Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:23:12 -0700 Subject: [PATCH 045/696] fix editor container overflow (#322588) --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 4 ++++ 1 file changed, 4 insertions(+) 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 e5819d841e9f0..771a7f2e091e2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -988,6 +988,10 @@ have to be updated for changes to the rules above, or to support more deeply nes rely on a visible focus/container outline for accessibility. */ border-color: var(--vscode-input-border, transparent); overflow: visible; + + > .chat-editor-container { + overflow: hidden; + } } .monaco-workbench .interactive-session .chat-input-container.working.focused { From 754537e1332b4e418311ef99b01f7f3fb0f4cdbf Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 23 Jun 2026 19:24:02 +0100 Subject: [PATCH 046/696] Bump codicons version and add voiceModeCompact icon (#322562) bump codicons version to 0.0.46-21 and add voiceModeCompact icon Co-authored-by: mrleemurray --- package-lock.json | 8 ++++---- package.json | 2 +- remote/web/package-lock.json | 8 ++++---- remote/web/package.json | 2 +- src/vs/base/common/codiconsLibrary.ts | 1 + 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 450186b2b5819..ef3fdc7d24a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@microsoft/mxc-sdk": "0.6.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -3665,9 +3665,9 @@ } }, "node_modules/@vscode/codicons": { - "version": "0.0.46-20", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-20.tgz", - "integrity": "sha512-o5OF7Agy3TBp2R3AXRWeL74MjEWXOLi8fp/HUvFZ/coXq5V9wEOC1okYadc1hZGNLcSmI9NXqYu057sldqbGiA==", + "version": "0.0.46-21", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-21.tgz", + "integrity": "sha512-HmSK8We0/P3epjr6Bpty8/6gs3N9TrLCU1KdL6CUmyOPvkdhZs+8BfBIX4DdxzrsOjz6zTrFp7LI3OMWcZtRqA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { diff --git a/package.json b/package.json index 4ab0081fbd2a2..981197c902d12 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@microsoft/mxc-sdk": "0.6.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 60d7bdb69e98a..2141a34b2766f 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-20", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-20.tgz", - "integrity": "sha512-o5OF7Agy3TBp2R3AXRWeL74MjEWXOLi8fp/HUvFZ/coXq5V9wEOC1okYadc1hZGNLcSmI9NXqYu057sldqbGiA==", + "version": "0.0.46-21", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-21.tgz", + "integrity": "sha512-HmSK8We0/P3epjr6Bpty8/6gs3N9TrLCU1KdL6CUmyOPvkdhZs+8BfBIX4DdxzrsOjz6zTrFp7LI3OMWcZtRqA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 68eb1a505f92d..765e00417ad25 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 060efd23c2ee4..dc21c50d3b92d 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -727,4 +727,5 @@ export const codiconsLibrary = { vscodeInsidersOutline: register('vscode-insiders-outline', 0xecc9), vscodeOutline: register('vscode-outline', 0xecca), voiceMode: register('voice-mode', 0xeccb), + voiceModeCompact: register('voice-mode-compact', 0xeccc), } as const; From 358425ad1781c936880b20ef6a8da6502e5311e8 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 23 Jun 2026 11:35:52 -0700 Subject: [PATCH 047/696] add deprecation text to ollama vendor --- extensions/copilot/package.json | 5 ++- .../chatManagement/chatModelsWidget.ts | 38 +++++++++++++++++-- .../chatManagement/media/chatModelsWidget.css | 17 +++++++++ .../contrib/chat/common/languageModels.ts | 20 +++++++++- .../chatManagement/chatModelsWidget.test.ts | 21 +++++++++- 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 0e917130e913e..501264feeb4e3 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1835,7 +1835,10 @@ }, { "vendor": "ollama", - "displayName": "Ollama", + "displayName": "Ollama (Deprecated)", + "deprecation": { + "link": "vscode:extension/Ollama.ollama" + }, "configuration": { "type": "object", "properties": { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 945866283b149..68ccffa530e22 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -26,6 +26,7 @@ import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js import { Codicon } from '../../../../../base/common/codicons.js'; import { ChatModelsViewModel, ILanguageModel, ILanguageModelEntry, ILanguageModelProviderEntry, ILanguageModelGroupEntry, SEARCH_SUGGESTIONS, isLanguageModelProviderEntry, isLanguageModelGroupEntry, IViewModelEntry, isStatusEntry, IStatusEntry } from './chatModelsViewModel.js'; import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; +import { Link } from '../../../../../platform/opener/browser/link.js'; import { SuggestEnabledInput } from '../../../codeEditor/browser/suggestEnabledInput/suggestEnabledInput.js'; import { Delayer } from '../../../../../base/common/async.js'; import { settingsTextInputBorder } from '../../../preferences/common/settingsEditorColorRegistry.js'; @@ -179,13 +180,21 @@ export function buildAddModelsDropdownActions( return []; } - // Sort vendors alphabetically by displayName, but pin "OpenAI Compatible (Deprecated)" (customoai) - // at the end of the sorted list and "Custom Endpoint" (customendpoint) after a separator at the very end. + // Sort vendors alphabetically by displayName, but sink deprecated providers (those declaring a + // `deprecation.link`, e.g. Ollama) to the end of the list. "OpenAI Compatible (Deprecated)" (customoai) + // is pinned after the sorted list and "Custom Endpoint" (customendpoint) after a separator at the very end. const customEndpointVendor = configurableVendors.find(v => v.vendor === 'customendpoint'); const customOaiVendor = configurableVendors.find(v => v.vendor === 'customoai'); const sortedVendors = configurableVendors .filter(v => v.vendor !== 'customendpoint' && v.vendor !== 'customoai') - .sort((a, b) => a.displayName.localeCompare(b.displayName)); + .sort((a, b) => { + const aDeprecated = a.deprecation?.link ? 1 : 0; + const bDeprecated = b.deprecation?.link ? 1 : 0; + if (aDeprecated !== bDeprecated) { + return aDeprecated - bDeprecated; + } + return a.displayName.localeCompare(b.displayName); + }); if (customOaiVendor) { sortedVendors.push(customOaiVendor); } @@ -493,6 +502,8 @@ interface IModelNameColumnTemplateData extends IModelTableColumnTemplateData { readonly statusIcon: HTMLElement; readonly nameLabel: HighlightedLabel; readonly modelStatusIcon: HTMLElement; + readonly deprecationLinkContainer: HTMLElement; + readonly deprecationLink: Link; } class ModelNameColumnRenderer extends ModelsTableColumnRenderer { @@ -501,7 +512,8 @@ class ModelNameColumnRenderer extends ModelsTableColumnRenderer.' URI to open a replacement extension in the Extensions view.") + } + } + }, when: { type: 'string', description: localize('vscode.extension.contributes.languageModels.when', "Condition which must be true to show this language model chat provider in the Manage Models list.") @@ -608,7 +618,14 @@ const languageModelChatProviderType = { } } as const satisfies IJSONSchema; -export type IUserFriendlyLanguageModel = TypeFromJsonSchema; +export type IUserFriendlyLanguageModel = Omit, 'deprecation'> & { + /** + * Marks a provider as deprecated. The Manage Models view renders a link + * (pointing to a replacement, e.g. a `vscode:extension/.` URI) + * next to the provider name. Optional so existing provider descriptors are unaffected. + */ + readonly deprecation?: { readonly link?: string }; +}; export interface ILanguageModelProviderDescriptor extends IUserFriendlyLanguageModel { readonly isDefault: boolean; @@ -841,6 +858,7 @@ export class LanguageModelsService implements ILanguageModelsService { displayName: item.displayName, configuration: item.configuration, managementCommand: item.managementCommand, + deprecation: item.deprecation, when: item.when, isDefault: item.vendor === COPILOT_VENDOR_ID }; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts index 1bb4487f0c1c8..fb1bcf306fceb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts @@ -37,8 +37,8 @@ function createModel(overrides: Partial = {}): ILang } as ILanguageModel; } -function createVendor(vendor: string, displayName: string): ILanguageModelProviderDescriptor { - return { vendor, displayName, isDefault: false } as ILanguageModelProviderDescriptor; +function createVendor(vendor: string, displayName: string, deprecation?: { link?: string }): ILanguageModelProviderDescriptor { + return { vendor, displayName, isDefault: false, deprecation } as ILanguageModelProviderDescriptor; } suite('ChatModelsWidget', () => { @@ -233,6 +233,23 @@ suite('ChatModelsWidget', () => { ran: ['acme', 'customendpoint'], }); }); + + test('sinks deprecated providers to the end of the sorted list', () => { + const vendors = [ + createVendor('zebra', 'Zebra'), + createVendor('ollama', 'Ollama (Deprecated)', { link: 'vscode:extension/Ollama.ollama' }), + createVendor('acme', 'Acme'), + createVendor('customoai', 'OpenAI Compatible (Deprecated)'), + createVendor('customendpoint', 'Custom Endpoint'), + ]; + + const actions = buildAddModelsDropdownActions(vendors, true, () => { }); + + assert.deepStrictEqual( + actions.map(a => a instanceof Separator ? 'separator' : a.id), + ['enable-acme', 'enable-zebra', 'enable-ollama', 'enable-customoai', 'separator', 'enable-customendpoint'], + ); + }); }); }); From 594a3e5d4818ba8a8c2c705c576c3af4203dc99e Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 23 Jun 2026 11:36:44 -0700 Subject: [PATCH 048/696] add tests for the launcher --- .../node/copilot/copilotSessionLauncher.ts | 109 ++++++++------ .../test/node/copilotSessionLauncher.test.ts | 141 ++++++++++++++++++ 2 files changed, 206 insertions(+), 44 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 4ca27076e1ac7..53cc449731032 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -196,6 +196,61 @@ export function getCopilotContextTier(model: ModelSelection | undefined): Sessio return isContextTier(contextTier) ? contextTier : undefined; } +/** + * Resolve the BYOK provider/model session config for `sessionId` from the + * renderer's active bridge. Returns empty — the session launches without BYOK + * models — when BYOK is gated off (no active bridge), when the renderer reports + * no BYOK models, or when enumeration fails; `startProxy` is invoked only once + * at least one model is present. + * + * Each vendor maps to one `type: 'openai'` / `wireApi: 'completions'` provider + * whose `baseUrl` points at the proxy and authenticates with the session-scoped + * `Bearer .`; each model is surfaced under the + * provider-qualified selection id `vendor/id`, matching what the renderer's + * `AgentHostByokLmHandler` resolves. + * + * Extracted from {@link CopilotSessionLauncher} so the synthesis and gating are + * unit-testable without instantiating the launcher; the launcher passes a + * `startProxy` thunk that memoizes the single shared proxy handle. + */ +export async function resolveByokSessionConfig( + sessionId: string, + bridgeRegistry: IByokLmBridgeRegistry, + startProxy: () => Promise, + logService: ILogService, +): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { + const connection = bridgeRegistry.getActive(); + if (!connection) { + return {}; + } + let byokModels: IByokLmModelInfo[]; + try { + byokModels = await connection.listModels(); + } catch (err) { + logService.warn(`[Copilot:${sessionId}] Failed to enumerate BYOK models from renderer bridge`, err); + return {}; + } + if (byokModels.length === 0) { + return {}; + } + const handle = await startProxy(); + const providers: NamedProviderConfig[] = [...new Set(byokModels.map(m => m.vendor))].map(vendor => ({ + name: vendor, + type: 'openai', + wireApi: 'completions', + baseUrl: handle.providerBaseUrl(vendor), + bearerToken: `${handle.nonce}.${sessionId}`, + })); + const models: ProviderModelConfig[] = byokModels.map(m => ({ + id: m.id, + provider: m.vendor, + ...(m.name !== undefined ? { name: m.name } : {}), + ...(m.maxContextWindowTokens !== undefined ? { maxContextWindowTokens: m.maxContextWindowTokens } : {}), + })); + logService.info(`[Copilot:${sessionId}] Wired ${models.length} BYOK model(s) across ${providers.length} provider(s) via loopback proxy ${handle.baseUrl}`); + return { providers, models }; +} + export class CopilotSessionLauncher implements ICopilotSessionLauncher { private _byokProxyHandle: Promise | undefined; @@ -306,51 +361,17 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } /** - * Synthesize SDK provider/model config for the renderer's BYOK models so the - * runtime issues their inference against the node-side loopback proxy (which - * bridges back to the renderer LM API). Returns empty when BYOK is gated off - * (no active bridge), the renderer reports no BYOK models, or enumeration - * fails — in each case the session simply launches without BYOK models. - * - * Each provider points at `proxy.providerBaseUrl(vendor)` and authenticates - * with the session-scoped `Bearer .`; each model surfaces - * under the provider-qualified selection id `vendor/id`, matching what the - * renderer's `AgentHostByokLmHandler` resolves. + * Launcher-bound wrapper over {@link resolveByokSessionConfig}: supplies the + * active bridge registry and a `startProxy` thunk that memoizes the single + * shared proxy handle for this launcher (started lazily on first use). */ - private async _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { - const connection = this._byokLmBridgeRegistry.getActive(); - if (!connection) { - return {}; - } - let byokModels: IByokLmModelInfo[]; - try { - byokModels = await connection.listModels(); - } catch (err) { - this._logService.warn(`[Copilot:${sessionId}] Failed to enumerate BYOK models from renderer bridge`, err); - return {}; - } - if (byokModels.length === 0) { - return {}; - } - if (!this._byokProxyHandle) { - this._byokProxyHandle = this._byokLmProxyService.start(); - } - const handle = await this._byokProxyHandle; - const providers: NamedProviderConfig[] = [...new Set(byokModels.map(m => m.vendor))].map(vendor => ({ - name: vendor, - type: 'openai', - wireApi: 'completions', - baseUrl: handle.providerBaseUrl(vendor), - bearerToken: `${handle.nonce}.${sessionId}`, - })); - const models: ProviderModelConfig[] = byokModels.map(m => ({ - id: m.id, - provider: m.vendor, - ...(m.name !== undefined ? { name: m.name } : {}), - ...(m.maxContextWindowTokens !== undefined ? { maxContextWindowTokens: m.maxContextWindowTokens } : {}), - })); - this._logService.info(`[Copilot:${sessionId}] Wired ${models.length} BYOK model(s) across ${providers.length} provider(s) via loopback proxy ${handle.baseUrl}`); - return { providers, models }; + private _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { + return resolveByokSessionConfig(sessionId, this._byokLmBridgeRegistry, () => { + if (!this._byokProxyHandle) { + this._byokProxyHandle = this._byokLmProxyService.start(); + } + return this._byokProxyHandle; + }, this._logService); } private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts new file mode 100644 index 0000000000000..d7005588d4ebe --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../log/common/log.js'; +import type { IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; +import { resolveByokSessionConfig } from '../../node/copilot/copilotSessionLauncher.js'; + +/** + * Covers the BYOK provider/model synthesis the launcher feeds into + * `createSession` / `resumeSession`. The first four tests pin the gating and + * graceful-degradation branches plus the exact SDK config shape using a real + * {@link ByokLmBridgeRegistry} and a counting proxy thunk (no real proxy). The + * last test wires the synthesized config straight into a live + * {@link ByokLmProxyService} and POSTs at it, proving the launcher's output is + * consumable end-to-end: provider `baseUrl` + `Bearer .` + + * `model = id` route through the proxy to the renderer bridge. + */ +suite('resolveByokSessionConfig', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + const log = new NullLogService(); + + /** Minimal bridge connection: a scripted `listModels` and an unused `chat`. */ + function connectionOf(listModels: () => Promise) { + return { chat: async (): Promise => ({ content: '' }), listModels }; + } + + /** A fake proxy handle plus a `startProxy` thunk that records its call count. */ + function countingProxy() { + let starts = 0; + const handle: IByokLmProxyHandle = { + baseUrl: 'http://127.0.0.1:1', + nonce: 'NONCE', + providerBaseUrl: vendor => `http://127.0.0.1:1/v/${vendor}`, + dispose: () => { }, + }; + return { + get starts() { return starts; }, + startProxy: async () => { starts++; return handle; }, + }; + } + + test('returns empty and never starts the proxy when no bridge is active', async () => { + const registry = new ByokLmBridgeRegistry(); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('returns empty and never starts the proxy when the bridge reports no models', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => [])); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('returns empty and never starts the proxy when enumeration fails', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => { throw new Error('renderer gone'); })); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('synthesizes deduped providers and per-model config from the active bridge', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => [ + { vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 200000 }, + { vendor: 'acme', id: 'gpt', name: undefined, maxContextWindowTokens: undefined }, + { vendor: 'globex', id: 'llama', name: 'Globex Llama' }, + ])); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.strictEqual(proxy.starts, 1); + assert.deepStrictEqual(config, { + providers: [ + { name: 'acme', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/acme', bearerToken: 'NONCE.sess-1' }, + { name: 'globex', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/globex', bearerToken: 'NONCE.sess-1' }, + ], + models: [ + { id: 'claude', provider: 'acme', name: 'Acme Claude', maxContextWindowTokens: 200000 }, + { id: 'gpt', provider: 'acme' }, + { id: 'llama', provider: 'globex', name: 'Globex Llama' }, + ], + }); + }); + + test('synthesized provider config routes through a live proxy to the bridge', async () => { + const registry = new ByokLmBridgeRegistry(); + let captured: IByokLmChatRequest | undefined; + const registration = registry.register('client-1', { + chat: async (request) => { captured = request; return { content: 'hello from byok' }; }, + listModels: async () => [{ vendor: 'acme', id: 'claude' }], + }); + const service = new ByokLmProxyService(log, registry); + let handle: IByokLmProxyHandle | undefined; + + const config = await resolveByokSessionConfig(sessionId, registry, async () => (handle = await service.start()), log); + const provider = config.providers![0]; + const model = config.models![0]; + try { + const response = await fetch(`${provider.baseUrl}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.bearerToken}` }, + body: JSON.stringify({ model: model.id, messages: [{ role: 'user', content: 'hi' }] }), + }); + assert.strictEqual(response.status, 200); + const text = await response.text(); + assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); + } finally { + handle?.dispose(); + registration.dispose(); + service.dispose(); + } + assert.strictEqual(captured?.vendor, 'acme'); + assert.strictEqual(captured?.modelId, 'claude'); + }); +}); From 0035fb2e7ed195dfc460e452b7c16ebbf66b22fc Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Tue, 23 Jun 2026 23:49:29 +0500 Subject: [PATCH 049/696] nes: track originating model patch for streamed edits (#322573) PR #322438 allowed splitting one model-sent diff-patch into multiple LineReplacements, breaking the invariant that the Nth shown edit corresponded to the Nth model patch (progressive ghost-text reveal already broke it too). As a result, telemetry could no longer attribute a served edit back to the model patch it came from. Stamp a 0-based `patchIndex` on each Patch in extractEdits, thread it through the streamed-edit -> cache -> telemetry pipeline, and emit a new `sourcePatchIndex` telemetry measurement. All fragments produced from the same patch (diff splitting or ghost-text early + continuation) share the index. The field is optional since the edit-window and INSERT response formats have no patch structure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../inlineEdits/node/nextEditCache.ts | 25 +++- .../inlineEdits/node/nextEditProvider.ts | 32 ++++- .../node/nextEditProviderTelemetry.ts | 11 ++ .../test/node/nextEditProviderCaching.spec.ts | 54 +++++++- .../xtab/node/xtabPatchResponseHandler.ts | 41 ++++-- .../node/xtabPatchResponseHandler.spec.ts | 127 ++++++++++++++++++ .../common/statelessNextEditProvider.ts | 8 ++ 7 files changed, 284 insertions(+), 14 deletions(-) diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts index 3659e0c210f89..f4e740cd5426d 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts @@ -33,6 +33,18 @@ export interface CachedEditOpts { * the cached entry is not served. */ cursorOffset?: number; + /** + * Zero-based index of the model-emitted patch this edit originated from + * (diff-patch response format). `undefined` for formats without an explicit + * patch structure. @see StreamedEdit.patchIndex + */ + patchIndex?: number; + /** + * Patch indices for the bundled `nextEdits`, aligned by position. Stored on the + * first cached entry so that edits served later via rebase (addressed by + * `rebasedEditIndex` into the bundle) can be attributed to the right model patch. + */ + patchIndices?: readonly (number | undefined)[]; } export interface CachedEdit { @@ -56,6 +68,17 @@ export interface CachedEdit { * When caching multiple edits, this is the order in which they were applied. */ subsequentN?: number; + /** + * Zero-based index of the model-emitted patch this edit originated from + * (diff-patch response format). @see StreamedEdit.patchIndex + */ + patchIndex?: number; + /** + * Patch indices for the bundled `edits`, aligned by position. Present on the + * first cached entry (the one carrying the `edits` bundle) so rebased subsequent + * edits, addressed by `rebasedEditIndex`, can be attributed to the right patch. + */ + patchIndices?: readonly (number | undefined)[]; source: NextEditFetchRequest; cacheTime: number; /** @@ -236,7 +259,7 @@ class DocumentEditCache { public setKthNextEdit(documentContents: StringText, editWindow: OffsetRange | undefined, nextEdit: StringReplacement, nextEdits: StringReplacement[] | undefined, userEditSince: StringEdit | undefined, subsequentN: number, source: NextEditFetchRequest, opts: CachedEditOpts): CachedEdit { const key = this._getKey(documentContents.value); - const cachedEdit: CachedEdit = { docId: this.docId, edit: nextEdit, edits: nextEdits, detailedEdits: [], userEditSince, subsequentN, source, documentBeforeEdit: documentContents, editWindow, originalEditWindow: opts.originalEditWindow, cacheTime: Date.now(), isFromCursorJump: opts.isFromCursorJump, cursorOffsetAtCacheTime: opts.cursorOffset }; + const cachedEdit: CachedEdit = { docId: this.docId, edit: nextEdit, edits: nextEdits, detailedEdits: [], userEditSince, subsequentN, patchIndex: opts.patchIndex, patchIndices: opts.patchIndices, source, documentBeforeEdit: documentContents, editWindow, originalEditWindow: opts.originalEditWindow, cacheTime: Date.now(), isFromCursorJump: opts.isFromCursorJump, cursorOffsetAtCacheTime: opts.cursorOffset }; if (userEditSince) { if (!checkEditConsistency(cachedEdit.documentBeforeEdit.value, userEditSince, this._doc.value.get().value, this._logger.createSubLogger('setKthNextEdit'))) { cachedEdit.userEditSince = undefined; diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index e13a6facef618..1550eaf6e856f 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -97,6 +97,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt docContents: StringText; editsSoFar: StringEdit; nextEdits: StringReplacement[]; + patchIndices: (number | undefined)[]; docId: DocumentId; }> { const statePerDoc = new CachedFunction((id: DocumentId) => { @@ -111,6 +112,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt docContents: baseDocState, editsSoFar: StringEdit.empty, nextEdits: [] as StringReplacement[], + patchIndices: [] as (number | undefined)[], docId: id, }; } @@ -122,6 +124,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt docContents: doc.documentAfterEdits, editsSoFar: StringEdit.empty, nextEdits: [] as StringReplacement[], + patchIndices: [] as (number | undefined)[], docId: id, }; }); @@ -130,6 +133,23 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt return statePerDoc; } +/** + * Computes the originating model-patch index for a served edit. In the rebase path + * the served edit is addressed by `rebasedEditIndex` into the entry's bundled + * `patchIndices`; otherwise the entry carries its own `patchIndex`. + * + * Invariant: `rebasedEditIndex` is only ever set for entry 0 (the sole entry given a + * `patchIndices` bundle), so whenever it is defined `patchIndices` is defined too. + * We therefore deliberately do NOT fall back to `patchIndex` in the rebase branch: a + * served bundle slot of `undefined` is a genuine "no originating patch" attribution + * and must not be masked by entry 0's own patch index. + */ +function getSourcePatchIndex(cachedEdit: CachedOrRebasedEdit): number | undefined { + return cachedEdit.rebasedEditIndex !== undefined + ? cachedEdit.patchIndices?.[cachedEdit.rebasedEditIndex] + : cachedEdit.patchIndex; +} + export interface NESInlineCompletionContext extends vscode.InlineCompletionContext { enforceCacheDelay: boolean; changeHint?: NesChangeHint; @@ -361,6 +381,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider { + const processEdit = (streamedEdit: { readonly edit: LineReplacement; readonly isFromCursorJump: boolean; readonly window?: OffsetRange; readonly originalWindow?: OffsetRange; readonly targetDocument?: DocumentId; readonly patchIndex?: number }, telemetry: IStatelessNextEditTelemetry): CachedOrRebasedEdit | undefined => { ++ithEdit; const myLogger = logger.createSubLogger('processEdit'); myLogger.trace(`processing edit #${ithEdit} (starts at 0)`); @@ -784,6 +808,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider { readonly isFromCache: boolean; readonly reusedRequest: ReusedRequestKind | undefined; readonly subsequentEditOrder: number | undefined; + readonly sourcePatchIndex: number | undefined; readonly activeDocumentOriginalLineCount: number | undefined; readonly activeDocumentEditsCount: number | undefined; readonly activeDocumentLanguageId: string | undefined; @@ -244,6 +245,7 @@ export class LlmNESTelemetryBuilder extends Disposable { isFromCache: this._isFromCache, reusedRequest: this._reusedRequest, subsequentEditOrder: this._subsequentEditOrder, + sourcePatchIndex: this._sourcePatchIndex, documentsCount, editsCount, activeDocumentEditsCount, @@ -354,6 +356,12 @@ export class LlmNESTelemetryBuilder extends Disposable { return this; } + private _sourcePatchIndex: number | undefined; + public setSourcePatchIndex(sourcePatchIndex: number | undefined): this { + this._sourcePatchIndex = sourcePatchIndex; + return this; + } + private _request: StatelessNextEditRequest | undefined; public setRequest(request: StatelessNextEditRequest): this { this._request = request; @@ -1006,6 +1014,7 @@ export class TelemetrySender implements IDisposable { isFromCache, reusedRequest, subsequentEditOrder, + sourcePatchIndex, activeDocumentLanguageId, activeDocumentOriginalLineCount, nLinesOfCurrentFileInPrompt, @@ -1107,6 +1116,7 @@ export class TelemetrySender implements IDisposable { "isFromCache": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the edit was provided from cache", "isMeasurement": true }, "reusedRequest": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the result was obtained by joining a pending request ('speculative' or 'async'), undefined for fresh requests and cache hits" }, "subsequentEditOrder": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Order of the subsequent edit", "isMeasurement": true }, + "sourcePatchIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Zero-based index of the model-emitted patch this served edit originated from (diff-patch format). A single model patch can expand into multiple edits that share this index; undefined for formats without explicit patches.", "isMeasurement": true }, "activeDocumentOriginalLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the active document before shortening", "isMeasurement": true }, "activeDocumentNLinesInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the active document included in prompt", "isMeasurement": true }, "wasPreviouslyRejected": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the edit was previously rejected", "isMeasurement": true }, @@ -1206,6 +1216,7 @@ export class TelemetrySender implements IDisposable { nextEditProviderDuration, isFromCache: this._boolToNum(isFromCache), subsequentEditOrder, + sourcePatchIndex, activeDocumentOriginalLineCount, activeDocumentNLinesInPrompt: nLinesOfCurrentFileInPrompt, wasPreviouslyRejected: this._boolToNum(wasPreviouslyRejected), diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts index 5881855dde1ba..b69ce8e9f38af 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts @@ -58,7 +58,7 @@ describe('NextEditProvider Caching', () => { afterAll(() => { disposableStore.dispose(); }); - function createStatelessNextEditProvider(): IStatelessNextEditProvider { + function createStatelessNextEditProvider(patchIndices?: readonly (number | undefined)[]): IStatelessNextEditProvider { return { ID: 'TestNextEditProvider', provideNextEdit: async function*(request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) { @@ -83,8 +83,11 @@ describe('NextEditProvider Caching', () => { ) ] ); + let editIndex = 0; for (const edit of lineEdit.replacements) { - yield new WithStatelessProviderTelemetry({ targetDocument: request.getActiveDocument().id, edit, isFromCursorJump: false }, telemetryBuilder.build(Result.ok(undefined))); + const patchIndex = patchIndices ? patchIndices[editIndex] : undefined; + editIndex++; + yield new WithStatelessProviderTelemetry({ targetDocument: request.getActiveDocument().id, edit, isFromCursorJump: false, patchIndex }, telemetryBuilder.build(Result.ok(undefined))); } const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, undefined); return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions))); @@ -331,4 +334,51 @@ describe('NextEditProvider Caching', () => { expect(secondCacheEntry).toBe(firstCacheEntry); expect(secondCacheEntry.wasRenderedAsInlineSuggestion).toBe(true); }); + + it('attributes each served edit to its originating model patch via sourcePatchIndex', async () => { + const obsWorkspace = new MutableObservableWorkspace(); + const obsGit = new ObservableGit(gitExtensionService); + // Edits are served in line order: the z parameter (patch 0), the getDistance + // body (also patch 0, i.e. a split of the same model patch per PR #322438), + // then the variable declaration (patch 1). + const statelessNextEditProvider = createStatelessNextEditProvider([0, 0, 1]); + + const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger); + + const doc = obsWorkspace.addDocument({ + id: DocumentId.create(URI.file('/test/test.ts').toString()), + initialValue: outdent` + class Point { + constructor( + private readonly x: number, + private readonly y: number, + ) { } + getDistance() { + return Math.sqrt(this.x ** 2 + this.y ** 2); + } + } + + const myPoint = new Point(0, 1);`.trimStart() + }); + doc.setSelection([new OffsetRange(1, 1)], undefined); + doc.applyEdit(StringEdit.insert(11, '3D')); + + const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false }; + const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context); + const cancellationToken = CancellationToken.None; + + const servedPatchIndices: (number | undefined)[] = []; + for (let i = 0; i < 3; i++) { + const tb = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + const result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb.nesBuilder); + assert(result.result?.edit, `expected an edit on call ${i + 1}`); + servedPatchIndices.push(tb.nesBuilder.build(false).sourcePatchIndex); + tb.dispose(); + doc.applyEdit(result.result.edit.toEdit()); + } + + // The first (fresh) edit must be attributed too, not just the cached ones; + // the split pair shares patch 0 while the final edit comes from patch 1. + expect(servedPatchIndices).toEqual([0, 0, 1]); + }); }); diff --git a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts index 63120ddec1b6f..542a6a109f287 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts @@ -32,23 +32,29 @@ class Patch { */ public readonly filePath: string, public readonly lineNumZeroBased: number, + /** + * Zero-based index of the model-emitted patch this object represents. Patches + * derived from the same model header (e.g. the early + continuation patches of a + * progressive ghost-text reveal) share the same `patchIndex`. + */ + public readonly patchIndex: number, ) { } - public static ofLine(line: string): Patch | null { + public static ofLine(line: string, patchIndex: number): Patch | null { const match = line.match(/^(.+):(\d+)$/); if (!match) { return null; } const [, filename, lineNumber] = match; - return new Patch(filename, parseInt(lineNumber, 10)); + return new Patch(filename, parseInt(lineNumber, 10), patchIndex); } /** * Creates a pure-insertion patch (no removed lines) at the given line. * Used for the continuation portion of a ghost-text progressive reveal. */ - public static insertion(filePath: string, lineNumZeroBased: number): Patch { - return new Patch(filePath, lineNumZeroBased); + public static insertion(filePath: string, lineNumZeroBased: number, patchIndex: number): Patch { + return new Patch(filePath, lineNumZeroBased, patchIndex); } addLine(line: string): boolean { @@ -399,6 +405,7 @@ export namespace XtabPatchResponseHandler { isFromCursorJump: false, targetDocument, window, + patchIndex: edit.patchIndex, } satisfies StreamedEdit; } } @@ -452,14 +459,29 @@ export namespace XtabPatchResponseHandler { export async function* extractEdits(linesStream: AsyncIterable, cursorLineZeroBased?: number, activeDocRelativePath?: string): AsyncGenerator { let currentPatch: Patch | null = null; let isFirstPatch = true; + // Tracks whether we've already attempted progressive reveal (succeeds or fails only once). let progressiveRevealDone = false; + + // Monotonic 0-based index assigned to each real model patch header. Derived + // patches (ghost-text early + continuation) inherit their source's index. + let nextPatchIndex = 0; + // Parses a header line into a patch, allocating it the next patch index. + // Returns null for lines that aren't valid headers, which don't consume an index. + const parseNextPatchHeader = (line: string): Patch | null => { + const patch = Patch.ofLine(line, nextPatchIndex); + if (patch !== null) { + nextPatchIndex++; + } + return patch; + }; + for await (const line of linesStream) { if (line.trim() === ResponseTags.NO_EDIT) { break; } if (currentPatch === null) { - currentPatch = Patch.ofLine(line); + currentPatch = parseNextPatchHeader(line); continue; } if (currentPatch.addLine(line)) { @@ -472,13 +494,16 @@ export namespace XtabPatchResponseHandler { && currentPatch.removedLines.length >= 1 ) { if (isGhostTextPatch(currentPatch, cursorLineZeroBased, activeDocRelativePath)) { + // Both the early and continuation patches stem from the same + // model patch, so they share its index. + const sourcePatchIndex = currentPatch.patchIndex; // Yield the cursor-line replacement immediately - const earlyPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased); + const earlyPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased, sourcePatchIndex); earlyPatch.removedLines = [...currentPatch.removedLines]; earlyPatch.addedLines = [...currentPatch.addedLines]; yield earlyPatch; // Replace currentPatch with a continuation pure-insertion patch - currentPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased + 1); + currentPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased + 1, sourcePatchIndex); } progressiveRevealDone = true; } @@ -488,7 +513,7 @@ export namespace XtabPatchResponseHandler { if (currentPatch.removedLines.length > 0 || currentPatch.addedLines.length > 0) { yield currentPatch; } - currentPatch = Patch.ofLine(line); + currentPatch = parseNextPatchHeader(line); isFirstPatch = false; } if (currentPatch && (currentPatch.removedLines.length > 0 || currentPatch.addedLines.length > 0)) { diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts index ae8ede7eb17ee..44b190c86cdff 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts @@ -1189,4 +1189,131 @@ another_file.js: ]); }); }); + + describe('patchIndex attribution', () => { + + it('extractEdits assigns an incrementing index per model patch header', async () => { + const linesStream = AsyncIterUtils.fromArray([ + 'a.ts:1', + '-a', + '+A', + 'b.ts:2', + '-b', + '+B', + 'c.ts:3', + '-c', + '+C', + ]); + const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream)); + expect(patches.map(p => p.patchIndex)).toEqual([0, 1, 2]); + }); + + it('extractEdits does not advance the index for an invalid header', async () => { + const linesStream = AsyncIterUtils.fromArray([ + 'a.ts:1', + '-a', + '+A', + 'not a valid header', + 'b.ts:2', + '-b', + '+B', + ]); + const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream)); + // The invalid header is skipped, so the two valid patches remain 0 and 1. + expect(patches.map(p => p.patchIndex)).toEqual([0, 1]); + }); + + it('all fragments of a split patch share the originating patchIndex', async () => { + const docId = DocumentId.create('file:///test.ts'); + const docContent = '0\na\nb\nc\nd\ne\n'; + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '-a'; + yield '-b'; + yield '-c'; + yield '-d'; + yield '-e'; + yield '+a'; + yield '+B'; + yield '+c'; + yield '+D'; + yield '+e'; + } + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + false, + true, + ); + + expect(edits).toHaveLength(2); + expect(edits.map(e => e.patchIndex)).toEqual([0, 0]); + }); + + it('distinct model patches get distinct patchIndex values', async () => { + const docId = DocumentId.create('file:///test.ts'); + const docContent = '0\na\nb\nc\nd\n'; + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '-a'; + yield '+A'; + yield '/test.ts:3'; + yield '-c'; + yield '+C'; + } + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + ); + + expect(edits.map(e => e.patchIndex)).toEqual([0, 1]); + }); + + it('progressive ghost-text fragments share the patchIndex while a following patch advances it', async () => { + const docId = DocumentId.create('file:///test.ts'); + const docContent = 'function foo() {\n let x = 1;\n}\nbar();\n'; + // Cursor on line 2 (1-based) → cursorLineOffset = 1 + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(2, 5)); + + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '- let x = 1;'; + yield '+ let x = 1;'; + yield '+ let y = 2;'; + yield '/test.ts:3'; + yield '-bar();'; + yield '+baz();'; + } + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + true, + ); + + // First two edits are the ghost-text early + continuation of patch 0; + // the third edit comes from the second model patch. + expect(edits.map(e => e.patchIndex)).toEqual([0, 0, 1]); + }); + }); }); diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts index f43503bab05f6..b4d3cca449b2c 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts @@ -49,6 +49,14 @@ export type StreamedEdit = { * in either the original location or the jump target location. */ readonly originalWindow?: OffsetRange; + /** + * Zero-based index of the model-emitted patch this edit originated from, for the + * diff-patch response format. A single model patch can expand into several edits + * (per-patch diff splitting or progressive ghost-text reveal); all edits produced + * from the same patch share the same `patchIndex`. `undefined` for response formats + * that have no explicit patch structure (e.g. edit-window, INSERT). + */ + readonly patchIndex?: number; }; export type PushEdit = (edit: Result) => void; From 48dd21f8f475cfaada2a50a74ddc9753fc661c5b Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 23 Jun 2026 11:49:50 -0700 Subject: [PATCH 050/696] Readd legacy support for cloud model picker prices (#322011) --- .../common/languageModelAccess.ts | 24 ++++++++++++++++--- .../test/languageModelAccess.test.ts | 16 +++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index ce5823f104e2a..c36f3137f1e82 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -184,12 +184,19 @@ export function formatPricingLabel(pricing: IChatEndpointTokenPricing): string { } const TOKENS_PER_MILLION = 1_000_000; +const NANO_AIU_DIVISOR = 1_000_000_000; /** - * Raw token prices from the CAPI billing response (API 2026-06-01+, prices in AIUs). + * Raw token prices from the CAPI billing response. Supports both the tiered + * format (API 2026-06-01+, prices in AIUs) and the legacy flat format + * (pre-2026-06-01, prices in nano-AIUs) which is still used by some endpoints + * such as the cloud agents (`/agents/swe/models`) model picker. */ export interface IRawTokenPrices { batch_size?: number; + input_price?: number; + cache_price?: number; + output_price?: number; default?: { input_price?: number; cache_price?: number; cache_write_price?: number; output_price?: number; context_max?: number }; long_context?: { input_price?: number; cache_price?: number; cache_write_price?: number; output_price?: number; context_max?: number }; } @@ -209,6 +216,7 @@ export interface INormalizedTokenPricing { /** * Converts raw billing token prices into normalized AICs (credits) per million tokens. + * Handles both tiered (AIU) and legacy flat (nano-AIU) formats. * * When a `long_context` tier is present but its prices match the `default` tier, * it is omitted from the result. @@ -251,6 +259,16 @@ export function normalizeTokenPrices(tokenPrices: IRawTokenPrices | undefined): return { default: normalized, longContext }; } - // No valid pricing data - return undefined; + // Legacy flat format (pre-2026-06-01): values are in nano-AIUs + if (tokenPrices.input_price === undefined || tokenPrices.output_price === undefined) { + return undefined; + } + return { + default: { + inputPrice: (tokenPrices.input_price / NANO_AIU_DIVISOR) * scale, + outputPrice: (tokenPrices.output_price / NANO_AIU_DIVISOR) * scale, + cachePrice: tokenPrices.cache_price !== undefined ? (tokenPrices.cache_price / NANO_AIU_DIVISOR) * scale : undefined, + cacheWritePrice: undefined, + }, + }; } diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index 0beeb61674998..5040f58cde735 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -445,6 +445,22 @@ suite('normalizeTokenPrices', () => { assert.ok(result.longContext, 'long-context tier should be included when cache_write_price differs'); assert.strictEqual(result.longContext?.cacheWritePrice, 3); }); + + test('converts legacy flat nano-AIU prices to credits per 1M tokens', () => { + // Shape returned by the cloud agents endpoint (/agents/swe/models) + const result = normalizeTokenPrices({ + batch_size: 1_000_000, + input_price: 500_000_000_000, + output_price: 2_500_000_000_000, + cache_price: 50_000_000_000, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 500); + assert.strictEqual(result.default.outputPrice, 2500); + assert.strictEqual(result.default.cachePrice, 50); + assert.strictEqual(result.default.cacheWritePrice, undefined); + assert.strictEqual(result.longContext, undefined); + }); }); suite('formatPricingLabel', () => { From f7b60de497e4bd796967e2e746fa53d4a4c9e0c0 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 23 Jun 2026 12:30:40 -0700 Subject: [PATCH 051/696] chat: use plugin labels for plugin-scoped slash commands (#322590) - Uses plugin display labels to keep slash command names stable for plugin items installed under implementation-specific SHA paths. - Keeps customization provenance consistent across prompts, agent sessions, and harness providers so plugin-scoped commands resolve correctly. - Fixes #322519 (Commit message generated by Copilot) --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 2 +- .../api/browser/mainThreadChatAgents2.ts | 1 + .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostChatAgents2.ts | 1 + .../agentCustomizationContentExpander.ts | 12 +++-- .../agentCustomizationItemProvider.ts | 16 +++--- ...promptsServiceCustomizationItemProvider.ts | 3 ++ .../common/customizationHarnessService.ts | 4 +- .../chat/common/plugins/agentPluginService.ts | 6 +-- .../promptSyntax/service/promptsService.ts | 10 ++++ .../service/promptsServiceImpl.ts | 7 ++- .../agentCustomizationContentExpander.test.ts | 17 +++---- .../customizationHarnessService.test.ts | 26 ++++++++++ .../service/promptsService.test.ts | 49 +++++++++++++++++++ ...osed.chatSessionCustomizationProvider.d.ts | 7 +++ 15 files changed, 132 insertions(+), 30 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index ba9b61786c88a..6e12cb871f514 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -186,7 +186,7 @@ In core VS Code, customization items contributed by the default chat extension ( ### Management Editor Item Pipeline -All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, and status fields. +All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, plugin provenance (`pluginUri`/`pluginLabel`), and status fields. ``` promptsService ──→ PromptsServiceCustomizationItemProvider ──→ ICustomizationItem[] diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 744ab4e4be646..84e8dcc9ebc3f 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -779,6 +779,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA badgeTooltip: item.badgeTooltip, extensionId: item.extensionId, pluginUri: item.pluginUri ? URI.revive(item.pluginUri) : undefined, + pluginLabel: item.pluginLabel, userInvocable: item.userInvocable, })); }, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 964bd1c86ef54..5280a5bc5a750 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1817,6 +1817,7 @@ export interface IChatSessionCustomizationItemDto { readonly badge?: string; readonly extensionId?: string; readonly pluginUri?: UriComponents; + readonly pluginLabel?: string; readonly badgeTooltip?: string; readonly userInvocable?: boolean; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 1cdbdb4ae01bc..79471081a1968 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -852,6 +852,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS badgeTooltip: item.badgeTooltip, extensionId: item.extensionId, pluginUri: item.pluginUri, + pluginLabel: item.pluginLabel, userInvocable: item.userInvocable, } satisfies IChatSessionCustomizationItemDto)); } catch (err) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts index cca1c557031f1..83d98f17bc441 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts @@ -27,7 +27,7 @@ export class AgentCustomizationContentExpander { ) { } - async expandPluginContents(pluginUri: URI, groupKey: string, isBundleItem: boolean, source: AICustomizationSource, token: CancellationToken): Promise { + async expandPluginContents(pluginUri: URI, groupKey: string, isBundleItem: boolean, source: AICustomizationSource, pluginLabel: string | undefined, token: CancellationToken): Promise { // pluginUri is already an agent-host:// URI (from toRemoteUri), // so use it directly as the filesystem root. const fsRoot = pluginUri; @@ -55,9 +55,9 @@ export class AgentCustomizationContentExpander { continue; } if (promptType === PromptsType.skill) { - children.push(...await this.collectFromSkillDir(stat.stat.children, pluginUri, source, groupKey, isBundleItem, token)); + children.push(...await this.collectFromSkillDir(stat.stat.children, pluginUri, source, groupKey, isBundleItem, pluginLabel, token)); } else { - children.push(...await this.collectFromRegularDir(stat.stat.children, pluginUri, source, promptType, groupKey, isBundleItem, token)); + children.push(...await this.collectFromRegularDir(stat.stat.children, pluginUri, source, promptType, groupKey, isBundleItem, pluginLabel, token)); } } children.sort((a, b) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`)); @@ -72,7 +72,7 @@ export class AgentCustomizationContentExpander { * Emits one item per skill subfolder that contains a SKILL.md file. * The skill metadata comes from SKILL.md frontmatter. */ - private async collectFromSkillDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], pluginUri: URI, source: AICustomizationSource, groupKey: string, isBundleItem: boolean, token: CancellationToken): Promise { + private async collectFromSkillDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], pluginUri: URI, source: AICustomizationSource, groupKey: string, isBundleItem: boolean, pluginLabel: string | undefined, token: CancellationToken): Promise { type Entry = { name: string; resource: URI; isDirectory: boolean }; const eligible: Entry[] = []; const readMetaDataPromises = []; @@ -113,6 +113,7 @@ export class AgentCustomizationContentExpander { groupKey, extensionId: undefined, pluginUri: isBundleItem ? undefined : pluginUri, + pluginLabel: isBundleItem ? undefined : pluginLabel, userInvocable } satisfies ICustomizationItem); } @@ -125,7 +126,7 @@ export class AgentCustomizationContentExpander { * agents additionally surface userInvocable. Instruction (rules) * folders additionally accept `.mdc` files per the Open Plugins spec. */ - private async collectFromRegularDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], pluginUri: URI, source: AICustomizationSource, promptType: PromptsType, groupKey: string, isBundleItem: boolean, token: CancellationToken): Promise { + private async collectFromRegularDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], pluginUri: URI, source: AICustomizationSource, promptType: PromptsType, groupKey: string, isBundleItem: boolean, pluginLabel: string | undefined, token: CancellationToken): Promise { type Entry = { name: string; resource: URI; isDirectory: boolean }; const eligible: Entry[] = []; for (const child of entries) { @@ -162,6 +163,7 @@ export class AgentCustomizationContentExpander { groupKey, extensionId: undefined, pluginUri: isBundleItem ? undefined : pluginUri, + pluginLabel: isBundleItem ? undefined : pluginLabel, userInvocable: promptType === PromptsType.agent ? meta?.userInvocable : undefined, } satisfies ICustomizationItem); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index d45a8ec0cb27d..0b711083cea1c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -27,15 +27,15 @@ const REMOTE_HOST_GROUP = 'remote-host'; const REMOTE_CLIENT_GROUP = 'remote-client'; -type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType; statusMessage: string | undefined; enabled: boolean | undefined; childGroupKey: string; isBundleItem: boolean }; +type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType; statusMessage: string | undefined; enabled: boolean | undefined; childGroupKey: string; isBundleItem: boolean; pluginLabel: string | undefined }; export class AgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - /** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */ - private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; children: readonly ICustomizationItem[] }>(); + /** Cache: pluginUri → last expansion (keyed by nonce and label so we re-fetch on content or display-name changes). */ + private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; pluginLabel: string | undefined; children: readonly ICustomizationItem[] }>(); private readonly _contentExpander: AgentCustomizationContentExpander; constructor( @@ -207,7 +207,8 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto statusMessage: toStatusMessage(sessionCustomization.load), enabled: sessionCustomization.enabled, childGroupKey, - isBundleItem + isBundleItem, + pluginLabel: isBundleItem ? undefined : item.name, } satisfies PluginMeta; plugins.push(pluginMeta); expandPromises.push(this._expandPluginContents(pluginMeta, token)); @@ -258,11 +259,11 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto */ private async _expandPluginContents(plugin: PluginMeta, token: CancellationToken): Promise { const cached = this._expansionCache.get(plugin.item.uri); - if (cached && cached.nonce === plugin.nonce) { + if (cached && cached.nonce === plugin.nonce && cached.pluginLabel === plugin.pluginLabel) { return cached.children; } - const children = await this._contentExpander.expandPluginContents(plugin.item.uri, plugin.childGroupKey, plugin.isBundleItem, plugin.item.source, token); - this._expansionCache.set(plugin.item.uri, { nonce: plugin.nonce, children }); + const children = await this._contentExpander.expandPluginContents(plugin.item.uri, plugin.childGroupKey, plugin.isBundleItem, plugin.item.source, plugin.pluginLabel, token); + this._expansionCache.set(plugin.item.uri, { nonce: plugin.nonce, pluginLabel: plugin.pluginLabel, children }); return children; } } @@ -333,4 +334,3 @@ function isSyntheticBundle(customization: Customization): boolean { return false; } } - diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 6f73e8a04e66f..fe3527362e98e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -117,6 +117,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt badgeTooltip: uiTooltip, extensionId: skill.extension?.identifier.value, pluginUri: skill.pluginUri, + pluginLabel: skill.pluginLabel, userInvocable: skill.userInvocable }); } @@ -137,6 +138,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt badgeTooltip: uiTooltip, extensionId: file.extension?.identifier.value, pluginUri: file.pluginUri, + pluginLabel: file.pluginLabel, userInvocable: false }); } @@ -157,6 +159,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt enabled: !disabledUris.has(command.uri), extensionId: command.extension?.identifier.value, pluginUri: command.pluginUri, + pluginLabel: command.pluginLabel, userInvocable: command.userInvocable }); if (command.extension) { diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index f680278a3f0bf..b7d64147876d6 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -171,6 +171,8 @@ export interface ICustomizationItem { readonly extensionId: string | undefined; /** The URI of the plugin that contributed this customization, if any. */ readonly pluginUri: URI | undefined; + /** Human-readable name of the plugin that contributed this customization, if any. */ + readonly pluginLabel?: string; /** Server-reported loading status for this customization. */ readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ @@ -697,7 +699,7 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer result.push({ uri: item.uri, type: item.type as PromptsType.prompt | PromptsType.skill, - name: item.pluginUri ? getCanonicalPluginCommandId({ uri: item.pluginUri }, item.name) : item.name, + name: item.pluginUri ? getCanonicalPluginCommandId({ uri: item.pluginUri, label: item.pluginLabel }, item.name) : item.name, description: item.description, userInvocable: item.userInvocable ?? true, storage: narrowStorage, diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index cbce0faebe9ae..9645c13af2098 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -72,9 +72,8 @@ export const enum AgentPluginDiscoveryPriority { CopilotCli = 40, } -export function getCanonicalPluginCommandId(plugin: { readonly uri: URI }, commandName: string): string { - const pluginSegment = basename(plugin.uri); - const prefix = normalizePluginToken(pluginSegment); +export function getCanonicalPluginCommandId(plugin: { readonly uri: URI; readonly label?: string }, commandName: string): string { + const prefix = (plugin.label ? normalizePluginToken(plugin.label) : '') || normalizePluginToken(basename(plugin.uri)); const normalizedCommand = normalizePluginToken(commandName); if (normalizedCommand.startsWith(`${prefix}:`)) { return normalizedCommand; @@ -121,4 +120,3 @@ class AgentPluginDiscoveryRegistry { } export const agentPluginDiscoveryRegistry = new AgentPluginDiscoveryRegistry(); - diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 4f226f728604d..27a21895735ba 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -152,6 +152,11 @@ export interface IPromptPathBase { */ readonly pluginUri?: URI; + /** + * Human-readable name of the contributing plugin, used for plugin-scoped slash command names. + */ + readonly pluginLabel?: string; + /** * The source that produced this prompt path. */ @@ -351,6 +356,7 @@ export interface IChatPromptSlashCommand { readonly userInvocable: boolean; readonly extension?: IExtensionDescription; readonly pluginUri?: URI; + readonly pluginLabel?: string; /** * Optional session types that describe when this slash command should be offered. */ @@ -430,6 +436,10 @@ export interface IAgentSkill { * Optional plugin URI describing where this skill originated. */ readonly pluginUri?: URI; + /** + * Optional plugin display name describing where this skill originated. + */ + readonly pluginLabel?: string; /** * Optional extension metadata describing where this skill originated. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 2b7b4257445aa..b9cefc17db8fe 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -234,6 +234,7 @@ export class PromptsService extends Disposable implements IPromptsService { type: PromptsType.hook, name: getCanonicalPluginCommandId(plugin, hook.originalId), pluginUri: plugin.uri, + pluginLabel: plugin.label, source: PromptFileSource.Plugin, }); } @@ -264,6 +265,7 @@ export class PromptsService extends Disposable implements IPromptsService { type, name: getCanonicalPluginCommandId(plugin, item.name), pluginUri: plugin.uri, + pluginLabel: plugin.label, source: PromptFileSource.Plugin, }); } @@ -455,7 +457,7 @@ export class PromptsService extends Disposable implements IPromptsService { // For plugin resources, ensure the canonical plugin prefix is always preserved even when the // file's frontmatter overrides the name. const name = promptPath.source === PromptFileSource.Plugin && promptPath.pluginUri - ? getCanonicalPluginCommandId({ uri: promptPath.pluginUri }, rawName) + ? getCanonicalPluginCommandId({ uri: promptPath.pluginUri, label: promptPath.pluginLabel }, rawName) : rawName; const description = parsedPromptFile?.header?.description ?? promptPath.description; const argumentHint = parsedPromptFile?.header?.argumentHint; @@ -573,6 +575,7 @@ export class PromptsService extends Disposable implements IPromptsService { type: promptPath.type, extension: promptPath.extension, pluginUri: promptPath.pluginUri, + pluginLabel: promptPath.pluginLabel, description: promptPath.description, argumentHint: argumentHint, userInvocable: userInvocable ?? true, @@ -900,6 +903,7 @@ export class PromptsService extends Disposable implements IPromptsService { disableModelInvocation: file.disableModelInvocation ?? false, userInvocable: file.userInvocable ?? true, pluginUri: file.promptPath.pluginUri, + pluginLabel: file.promptPath.pluginLabel, extension: file.promptPath.extension, sessionTypes: file.promptPath.sessionTypes, }); @@ -1565,4 +1569,3 @@ export namespace CustomAgent { } } - diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts index 956575bf92d46..9f923305739dd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts @@ -25,8 +25,8 @@ const REMOTE_CLIENT_GROUP = 'remote-client'; // Helpers // --------------------------------------------------------------------------- -function expand(expander: AgentCustomizationContentExpander, pluginUri: URI, groupKey: string, isBundleItem: boolean, source: AICustomizationSource, token: CancellationToken): Promise { - return expander.expandPluginContents(pluginUri, groupKey, isBundleItem, source, token); +function expand(expander: AgentCustomizationContentExpander, pluginUri: URI, groupKey: string, isBundleItem: boolean, source: AICustomizationSource, token: CancellationToken, pluginLabel?: string): Promise { + return expander.expandPluginContents(pluginUri, groupKey, isBundleItem, source, pluginLabel, token); } // --------------------------------------------------------------------------- @@ -464,7 +464,7 @@ suite('AgentCustomizationContentExpander', () => { } }); - test('isBundleItem=true clears pluginUri on child items', async () => { + test('isBundleItem=true clears pluginUri and pluginLabel on child items', async () => { const pluginRoot = URI.file('/plugins/bundle'); await mockFiles(fileService, [ { @@ -477,15 +477,14 @@ suite('AgentCustomizationContentExpander', () => { ]); const expander = new AgentCustomizationContentExpander(fileService, new NullLogService()); - const bundleItems = await expand(expander, pluginRoot, REMOTE_CLIENT_GROUP, true /* isBundleItem */, AICustomizationSources.plugin, CancellationToken.None); + const bundleItems = await expand(expander, pluginRoot, REMOTE_CLIENT_GROUP, true /* isBundleItem */, AICustomizationSources.plugin, CancellationToken.None, 'bundle-plugin'); - // Bundle items must not carry pluginUri for (const item of bundleItems) { - assert.strictEqual(item.pluginUri, undefined, `bundle item ${item.name} must have no pluginUri`); + assert.deepStrictEqual({ pluginUri: item.pluginUri, pluginLabel: item.pluginLabel }, { pluginUri: undefined, pluginLabel: undefined }, `bundle item ${item.name} must have no plugin provenance`); } }); - test('isBundleItem=false sets pluginUri to the plugin root on child items', async () => { + test('isBundleItem=false sets pluginUri and pluginLabel on child items', async () => { const pluginRoot = URI.file('/plugins/with-uri'); await mockFiles(fileService, [ { @@ -498,9 +497,9 @@ suite('AgentCustomizationContentExpander', () => { ]); const expander = new AgentCustomizationContentExpander(fileService, new NullLogService()); - const items = await expand(expander, pluginRoot, REMOTE_HOST_GROUP, false, AICustomizationSources.plugin, CancellationToken.None); + const items = await expand(expander, pluginRoot, REMOTE_HOST_GROUP, false, AICustomizationSources.plugin, CancellationToken.None, 'Datadog'); assert.strictEqual(items.length, 1); - assert.strictEqual(items[0].pluginUri?.toString(), pluginRoot.toString()); + assert.deepStrictEqual({ pluginUri: items[0].pluginUri?.toString(), pluginLabel: items[0].pluginLabel }, { pluginUri: pluginRoot.toString(), pluginLabel: 'Datadog' }); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index e0a80aa536b40..a19c3a098848e 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -405,6 +405,32 @@ suite('CustomizationHarnessService', () => { ]); }); + test('uses plugin label for plugin-scoped commands when provider plugin URI is a pinned SHA path', async () => { + const testSessionType = 'test-session-type'; + const testSessionResource = URI.parse('test-session-type://session'); + const pluginUri = URI.parse('file:///cache/agentPlugins/github/datadog/sha_b003fcad48c3a935ffe04b6218f5cf58fe2b6760'); + + const emitter = new Emitter(); + store.add(emitter); + const service = createService({ + id: testSessionType, + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.plugin] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ + { uri: URI.joinPath(pluginUri, 'skills', 'ddsetup', 'SKILL.md'), type: PromptsType.skill, source: 'plugin', name: 'ddsetup', description: 'Set up Datadog', extensionId: undefined, pluginUri, pluginLabel: 'datadog', userInvocable: undefined }, + ], + }, + }); + + const commands = await service.getSlashCommands(testSessionResource, CancellationToken.None); + assert.deepStrictEqual(commands.map(command => ({ name: command.name, description: command.description, type: command.type })), [ + { name: 'datadog:ddsetup', description: 'Set up Datadog', type: PromptsType.skill }, + ]); + }); + test('falls back to promptsService when the active harness has no provider', async () => { const testSessionType = 'test-session-type'; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 5c5ccd5800e75..7d3b295c90b84 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -4473,6 +4473,55 @@ suite('PromptsService', () => { testPluginsObservable.set([], undefined); }); + + test('plugin skill slash command prefix uses plugin label when install path is a pinned SHA', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const pluginUri = URI.file('/cache/agentPlugins/github/datadog/sha_b003fcad48c3a935ffe04b6218f5cf58fe2b6760'); + const skillUri = URI.joinPath(pluginUri, 'skills', 'ddsetup', 'SKILL.md'); + await mockFiles(fileService, [ + { + path: skillUri.path, + contents: [ + '---', + 'name: "ddsetup"', + 'description: "Set up Datadog"', + '---', + 'Datadog setup skill content', + ], + }, + ]); + + const enablement = observableValue('testPluginEnablement', 2 /* ContributionEnablementState.EnabledProfile */); + const plugin: IAgentPlugin = { + uri: pluginUri, + label: 'datadog', + enablement, + remove: () => { }, + hooks: observableValue('testPluginHooks', []), + commands: observableValue('testPluginCommands', []), + skills: observableValue('testPluginSkills', [{ uri: skillUri, name: 'ddsetup' }]), + agents: observableValue('testPluginAgents', []), + instructions: observableValue('testPluginInstructions', []), + mcpServerDefinitions: observableValue('testPluginMcpServerDefinitions', []), + }; + + testPluginsObservable.set([plugin], undefined); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + assert.deepStrictEqual(slashCommands + .filter(command => command.uri.toString() === skillUri.toString()) + .map(command => ({ name: command.name, description: command.description, type: command.type, storage: command.storage })), [{ + name: 'datadog:ddsetup', + description: 'Set up Datadog', + type: PromptsType.skill, + storage: PromptsStorage.plugin, + }]); + + testPluginsObservable.set([], undefined); + }); }); suite('hooks', () => { diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 8feb605f2b9b2..d1df4236de007 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -106,6 +106,13 @@ declare module 'vscode' { */ readonly pluginUri?: Uri; + /** + * Human-readable name of the plugin that contributed this customization, if any. + * Used to qualify plugin-scoped slash command names when `pluginUri` points to + * an implementation-specific install directory. + */ + readonly pluginLabel?: string; + /** * Optional group key for display grouping. Items sharing the same * `groupKey` are placed under a shared collapsible header in the From e8298bc3f43152fc0b8ff9162b350abb6fccb2a7 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 23 Jun 2026 12:31:45 -0700 Subject: [PATCH 052/696] sessions: move remote connections toggle to titlebar (#322592) Move Remote Connections to the Agents window titlebar while keeping the main editor chat input affordance for local agent-host sessions.\n\nFixes #320114\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 8 +- .../tunnelHost.contribution.ts | 147 ++++-------------- .../tunnelHost.contribution.test.ts | 74 +++++++++ .../contrib/chat}/common/tunnelHost.ts | 2 +- .../electron-browser/media/tunnelHost.css | 16 +- .../toggleRemoteConnectionsActionViewItem.ts | 24 +-- .../tunnelHost.contribution.ts | 130 ++++++++++++++++ .../electron-browser/tunnelHostService.ts | 43 ++--- src/vs/workbench/workbench.desktop.main.ts | 1 + 9 files changed, 268 insertions(+), 177 deletions(-) create mode 100644 src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts rename src/vs/{sessions/contrib/tunnelHost => workbench/contrib/chat}/common/tunnelHost.ts (100%) rename src/vs/{sessions/contrib/tunnelHost => workbench/contrib/chat}/electron-browser/media/tunnelHost.css (85%) rename src/vs/{sessions/contrib/tunnelHost => workbench/contrib/chat}/electron-browser/toggleRemoteConnectionsActionViewItem.ts (92%) create mode 100644 src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts rename src/vs/{sessions/contrib/tunnelHost => workbench/contrib/chat}/electron-browser/tunnelHostService.ts (87%) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 15bf0deeabce6..b06254e929575 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -85,7 +85,7 @@ The titlebar is a standalone implementation (`TitlebarPart`) — not extending ` |---------|---------|---------| | Left | `Menus.TitleBarLeftLayout` | Toggle sidebar, agent host filter | | Center | `Menus.CommandCenter` | Session picker widget (plus `Menus.TitleBarSessionMenu` for active-session actions) | -| Right | `Menus.TitleBarRightLayout` | Run script (split button), Open Terminal/VS Code, toggle auxiliary bar, account widget | +| Right | `Menus.TitleBarRightLayout` | Remote connections, run script (split button), Open Terminal/VS Code, toggle auxiliary bar, account widget | No menubar, no editor actions, no `WindowTitle` dependency. @@ -108,6 +108,12 @@ When multiple remote agent hosts are known, a dropdown pill in the left toolbar Shows the signed-in GitHub profile image (falls back to the account codicon). Clicking opens a combined account and Copilot status panel with sign-in/sign-out and settings actions. +### Remote Connections (Right) + +The remote connections toggle is a global titlebar action (`Menus.TitleBarRightLayout`) rather than a per-chat input action. This keeps tunnel hosting state visually scoped to the Agents window as a whole, so users do not interpret it as a setting that must be enabled separately for each chat session. + +This Agents-window placement is intentionally different from the main editor window: outside the Agents window the same toggle remains in `MenuId.ChatInputSecondary` for agent-host chat inputs. Keep both menu items mutually exclusive with `IsSessionsWindowContext` so the editor window keeps its chat-input affordance while the Agents window shows only the titlebar affordance. + --- ## 4. Sessions Part diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts index 69fbe51444247..ce85c69f2c19e 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts +++ b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts @@ -5,136 +5,45 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; +import { localize } from '../../../../nls.js'; +import { IActionViewItemService, type IActionViewItemFactory } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IOutputService } from '../../../../workbench/services/output/common/output.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; -import { ITunnelHostService } from '../common/tunnelHost.js'; -import { TUNNEL_HOST_LOG_ID } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { CONFIGURATION_KEY_MICROSOFT_AUTH, SHOW_TUNNEL_HOST_OUTPUT_ID, TunnelHostService } from './tunnelHostService.js'; -import { ToggleRemoteConnectionsActionViewItem } from './toggleRemoteConnectionsActionViewItem.js'; - -const TUNNEL_HOST_SHARING_KEY = 'tunnelHostSharing'; -const TUNNEL_HOST_SHARING_CONTEXT = new RawContextKey(TUNNEL_HOST_SHARING_KEY, false); -const TOGGLE_SHARING_ID = 'sessions.tunnelHost.toggleSharing'; - -const CATEGORY = localize2('tunnelHost.category', 'Remote Connections'); - -// Register the renderer-side service -registerSingleton(ITunnelHostService, TunnelHostService, InstantiationType.Delayed); - -/** - * Contribution that manages the tunnel host sharing context key - * and registers the toggle action in the sessions titlebar. - */ -class TunnelHostContribution extends Disposable implements IWorkbenchContribution { +import { ITunnelHostService } from '../../../../workbench/contrib/chat/common/tunnelHost.js'; +import { ToggleRemoteConnectionsActionViewItem } from '../../../../workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.js'; +import { TOGGLE_SHARING_ID, TUNNEL_HOST_SHARING_KEY } from '../../../../workbench/contrib/chat/electron-browser/tunnelHost.contribution.js'; +import { Menus } from '../../../browser/menus.js'; + +MenuRegistry.appendMenuItem(Menus.TitleBarRightLayout, { + command: { + id: TOGGLE_SHARING_ID, + title: localize('toggleSharing', "Allow Remote Connections"), + icon: Codicon.radioTower, + toggled: ContextKeyExpr.equals(TUNNEL_HOST_SHARING_KEY, true), + }, + group: 'navigation', + order: 90, + when: ContextKeyExpr.and(ChatContextKeys.enabled, IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated()) +}); - static readonly ID = 'workbench.contrib.tunnelHost'; +class SessionsTunnelHostTitlebarContribution extends Disposable implements IWorkbenchContribution { - private readonly _sharingContext: IContextKey; + static readonly ID = 'workbench.contrib.sessionsTunnelHostTitlebar'; constructor( - @IContextKeyService contextKeyService: IContextKeyService, @ITunnelHostService tunnelHostService: ITunnelHostService, @IActionViewItemService actionViewItemService: IActionViewItemService, ) { super(); - this._sharingContext = TUNNEL_HOST_SHARING_CONTEXT.bindTo(contextKeyService); - - // Keep context key in sync with service state - this._register(tunnelHostService.onDidChangeStatus(() => { - this._sharingContext.set(tunnelHostService.isSharing); - })); - - // Register custom action view item with pulse, hover, and toast - this._register(actionViewItemService.register( - MenuId.ChatInputSecondary, - TOGGLE_SHARING_ID, - (action, _options, instaService) => instaService.createInstance(ToggleRemoteConnectionsActionViewItem, action), - tunnelHostService.onDidChangeStatus, - )); + const viewItemFactory: IActionViewItemFactory = (action, _options, instantiationService) => { + return instantiationService.createInstance(ToggleRemoteConnectionsActionViewItem, action); + }; + this._register(actionViewItemService.register(Menus.TitleBarRightLayout, TOGGLE_SHARING_ID, viewItemFactory, tunnelHostService.onDidChangeStatus)); } } -// Register the toggle action -registerAction2(class ToggleRemoteConnectionsAction extends Action2 { - constructor() { - super({ - id: TOGGLE_SHARING_ID, - title: localize2("toggleSharing", "Allow Remote Connections"), - category: CATEGORY, - icon: Codicon.radioTower, - toggled: ContextKeyExpr.equals(TUNNEL_HOST_SHARING_KEY, true), - menu: { - id: MenuId.ChatInputSecondary, - order: 10, - group: 'navigation', - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), - ChatContextKeys.inQuickChat.negate(), - ContextKeyExpr.regex(ChatContextKeys.lockedCodingAgentId.key, /^agent-host-/), - ) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const tunnelHostService = accessor.get(ITunnelHostService); - const notificationService = accessor.get(INotificationService); - - try { - if (tunnelHostService.isSharing) { - await tunnelHostService.stopSharing(); - } else { - await tunnelHostService.startSharing(); - } - } catch (err) { - notificationService.notify({ - severity: Severity.Error, - message: localize('tunnelHost.error', "Failed to toggle remote connections: {0}", String(err)), - }); - } - } -}); - -// Register the show output action -registerAction2(class ShowTunnelHostOutputAction extends Action2 { - constructor() { - super({ - id: SHOW_TUNNEL_HOST_OUTPUT_ID, - title: localize2('showTunnelHostOutput', "Show Remote Connections Output"), - category: CATEGORY, - }); - } - - async run(accessor: ServicesAccessor): Promise { - const outputService = accessor.get(IOutputService); - await outputService.showChannel(TUNNEL_HOST_LOG_ID); - } -}); - -registerWorkbenchContribution2(TunnelHostContribution.ID, TunnelHostContribution, WorkbenchPhase.AfterRestored); - -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - type: 'object', - properties: { - [CONFIGURATION_KEY_MICROSOFT_AUTH]: { - description: localize('tunnelHost.enableMicrosoftAuth', "Enable Microsoft account authentication for agent host tunnels. When disabled, only GitHub authentication is used."), - type: 'boolean', - scope: ConfigurationScope.APPLICATION, - default: false, - tags: ['usesOnlineServices'], - }, - } -}); +registerWorkbenchContribution2(SessionsTunnelHostTitlebarContribution.ID, SessionsTunnelHostTitlebarContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts b/src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts new file mode 100644 index 0000000000000..f6c625988fbd4 --- /dev/null +++ b/src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import type { ContextKeyExpression, ContextKeyValue } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, RemoteNameContext } from '../../../../../workbench/common/contextkeys.js'; +import { Menus } from '../../../../browser/menus.js'; + +import '../../electron-browser/tunnelHost.contribution.js'; + +suite('Sessions - Tunnel Host Contribution', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('remote connections toggle is in Agents titlebar and non-Agents chat input', () => { + const findToggle = (menu: MenuId) => MenuRegistry.getMenuItems(menu) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessions.tunnelHost.toggleSharing'); + + const summarize = (menu: MenuId) => { + const item = findToggle(menu); + return item && { + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + }; + }; + + assert.deepStrictEqual({ + titlebar: summarize(Menus.TitleBarRightLayout), + chatInput: summarize(MenuId.ChatInputSecondary), + }, { + titlebar: { group: 'navigation', order: 90, icon: Codicon.radioTower.id }, + chatInput: { group: 'navigation', order: 10, icon: Codicon.radioTower.id }, + }); + + const titlebarToggle = findToggle(Menus.TitleBarRightLayout); + const chatInputToggle = findToggle(MenuId.ChatInputSecondary); + if (!titlebarToggle?.when || !chatInputToggle?.when) { + assert.fail('remote connections menu items should have when clauses'); + } + + const evalWhen = (when: ContextKeyExpression, values: Record) => { + return when.evaluate({ getValue: (key: string) => values[key] as T }); + }; + const agentHostChat = { + [ChatContextKeys.enabled.key]: true, + [ChatContextKeys.chatIsAgentHostSession.key]: true, + [IsAuxiliaryWindowContext.key]: false, + [RemoteNameContext.key]: '', + }; + + assert.deepStrictEqual({ + agentsTitlebar: evalWhen(titlebarToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: true }), + editorTitlebar: evalWhen(titlebarToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false }), + agentsChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: true }), + editorChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false }), + remoteEditorChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false, [RemoteNameContext.key]: 'ssh-remote' }), + }, { + agentsTitlebar: true, + editorTitlebar: false, + agentsChatInput: false, + editorChatInput: true, + remoteEditorChatInput: false, + }); + }); +}); diff --git a/src/vs/sessions/contrib/tunnelHost/common/tunnelHost.ts b/src/vs/workbench/contrib/chat/common/tunnelHost.ts similarity index 100% rename from src/vs/sessions/contrib/tunnelHost/common/tunnelHost.ts rename to src/vs/workbench/contrib/chat/common/tunnelHost.ts index 1c30d83d14988..7e24c6c4a27be 100644 --- a/src/vs/sessions/contrib/tunnelHost/common/tunnelHost.ts +++ b/src/vs/workbench/contrib/chat/common/tunnelHost.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ITunnelHostInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; export const ITunnelHostService = createDecorator('tunnelHostService'); diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css b/src/vs/workbench/contrib/chat/electron-browser/media/tunnelHost.css similarity index 85% rename from src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css rename to src/vs/workbench/contrib/chat/electron-browser/media/tunnelHost.css index 410f58c8f7a31..a1b55dd43b364 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css +++ b/src/vs/workbench/contrib/chat/electron-browser/media/tunnelHost.css @@ -6,20 +6,28 @@ .tunnel-host-toggle { display: flex; align-items: center; - gap: 4px; - height: 16px; - padding: 3px 6px; + justify-content: center; + box-sizing: border-box; + width: 24px; + height: 24px; + padding: 4px; cursor: pointer; color: var(--vscode-icon-foreground); border-radius: var(--vscode-cornerRadius-small); } +.tunnel-host-toggle:has(.tunnel-host-toast.visible) { + gap: 4px; + width: auto; + padding: 4px 6px; +} + .tunnel-host-toggle:hover { background-color: var(--vscode-toolbar-hoverBackground); } .tunnel-host-toggle:focus-visible { - outline: 1px solid var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: -1px; } diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/toggleRemoteConnectionsActionViewItem.ts b/src/vs/workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.ts similarity index 92% rename from src/vs/sessions/contrib/tunnelHost/electron-browser/toggleRemoteConnectionsActionViewItem.ts rename to src/vs/workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.ts index 51b8eee7c4208..3e2545b248d4d 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/toggleRemoteConnectionsActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.ts @@ -5,24 +5,19 @@ import './media/tunnelHost.css'; import * as dom from '../../../../base/browser/dom.js'; +import { IManagedHover, IManagedHoverContent } from '../../../../base/browser/ui/hover/hover.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; import { IAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { disposableTimeout } from '../../../../base/common/async.js'; import { localize } from '../../../../nls.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ITunnelHostService } from '../common/tunnelHost.js'; import { SHOW_TUNNEL_HOST_OUTPUT_ID } from './tunnelHostService.js'; -import { IManagedHover, IManagedHoverContent } from '../../../../base/browser/ui/hover/hover.js'; -/** - * Custom action view item for the toggle remote connections button. - * Provides pulse animation while connecting, hover with tunnel status, - * and a brief toast message after enabling. - */ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { private _iconElement: HTMLElement | undefined; @@ -55,14 +50,11 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { this.element.tabIndex = 0; this.element.role = 'button'; - // Icon this._iconElement = dom.append(this.element, dom.$('span.tunnel-host-icon')); this._iconElement.append(...renderLabelWithIcons(`$(${Codicon.radioTower.id})`)); - // Toast text (initially hidden) this._toastElement = dom.append(this.element, dom.$('span.tunnel-host-toast')); - // Hover const hoverDelegate = getDefaultHoverDelegate('element'); this._hover = this._register(this._hoverService.setupManagedHover( hoverDelegate, this.element, this._getHoverContent() @@ -79,18 +71,12 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { const isSharing = this._tunnelHostService.isSharing; const isConnecting = this._tunnelHostService.isConnecting; - // Toggle CSS classes for visual state this.element.classList.toggle('sharing', isSharing); this.element.classList.toggle('connecting', isConnecting); - - // Update hover content this._hover?.update(this._getHoverContent()); - - // Update ARIA this.element.setAttribute('aria-label', this._getAriaLabel()); this.element.setAttribute('aria-pressed', String(isSharing)); - // Show toast when transitioning to sharing if (isSharing && !this._wasSharing && !isConnecting) { this._showToast(); } else if (!isSharing && this._wasSharing) { @@ -152,8 +138,4 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { } return localize('tunnelHost.hover.idle', "Allow remote session access"); } - - override dispose(): void { - super.dispose(); - } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts new file mode 100644 index 0000000000000..6ff140c7c3338 --- /dev/null +++ b/src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { IActionViewItemService, type IActionViewItemFactory } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IsSessionsWindowContext, RemoteNameContext } from '../../../common/contextkeys.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { IOutputService } from '../../../services/output/common/output.js'; +import { ChatContextKeyExprs, ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { ITunnelHostService } from '../common/tunnelHost.js'; +import { CONFIGURATION_KEY_MICROSOFT_AUTH, SHOW_TUNNEL_HOST_OUTPUT_ID, TunnelHostService } from './tunnelHostService.js'; +import { TUNNEL_HOST_LOG_ID } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { ToggleRemoteConnectionsActionViewItem } from './toggleRemoteConnectionsActionViewItem.js'; + +export const TUNNEL_HOST_SHARING_KEY = 'tunnelHostSharing'; +export const TUNNEL_HOST_SHARING_CONTEXT = new RawContextKey(TUNNEL_HOST_SHARING_KEY, false); +export const TOGGLE_SHARING_ID = 'sessions.tunnelHost.toggleSharing'; + +const CATEGORY = localize2('tunnelHost.category', 'Remote Connections'); + +registerSingleton(ITunnelHostService, TunnelHostService, InstantiationType.Delayed); + +class TunnelHostContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.tunnelHost'; + + private readonly _sharingContext: IContextKey; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ITunnelHostService tunnelHostService: ITunnelHostService, + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + + this._sharingContext = TUNNEL_HOST_SHARING_CONTEXT.bindTo(contextKeyService); + this._sharingContext.set(tunnelHostService.isSharing); + + this._register(tunnelHostService.onDidChangeStatus(() => { + this._sharingContext.set(tunnelHostService.isSharing); + })); + + const viewItemFactory: IActionViewItemFactory = (action, _options, instantiationService) => { + return instantiationService.createInstance(ToggleRemoteConnectionsActionViewItem, action); + }; + this._register(actionViewItemService.register(MenuId.ChatInputSecondary, TOGGLE_SHARING_ID, viewItemFactory, tunnelHostService.onDidChangeStatus)); + } +} + +registerAction2(class ToggleRemoteConnectionsAction extends Action2 { + constructor() { + super({ + id: TOGGLE_SHARING_ID, + title: localize2('toggleSharing', "Allow Remote Connections"), + category: CATEGORY, + icon: Codicon.radioTower, + toggled: ContextKeyExpr.equals(TUNNEL_HOST_SHARING_KEY, true), + menu: { + id: MenuId.ChatInputSecondary, + order: 10, + group: 'navigation', + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + IsSessionsWindowContext.toNegated(), + RemoteNameContext.isEqualTo(''), + ChatContextKeyExprs.isAgentHostSession, + ) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const tunnelHostService = accessor.get(ITunnelHostService); + const notificationService = accessor.get(INotificationService); + + try { + if (tunnelHostService.isSharing) { + await tunnelHostService.stopSharing(); + } else { + await tunnelHostService.startSharing(); + } + } catch (err) { + notificationService.notify({ + severity: Severity.Error, + message: localize('tunnelHost.error', "Failed to toggle remote connections: {0}", String(err)), + }); + } + } +}); + +registerAction2(class ShowTunnelHostOutputAction extends Action2 { + constructor() { + super({ + id: SHOW_TUNNEL_HOST_OUTPUT_ID, + title: localize2('showTunnelHostOutput', "Show Remote Connections Output"), + category: CATEGORY, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const outputService = accessor.get(IOutputService); + await outputService.showChannel(TUNNEL_HOST_LOG_ID); + } +}); + +registerWorkbenchContribution2(TunnelHostContribution.ID, TunnelHostContribution, WorkbenchPhase.AfterRestored); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + type: 'object', + properties: { + [CONFIGURATION_KEY_MICROSOFT_AUTH]: { + description: localize('tunnelHost.enableMicrosoftAuth', "Enable Microsoft account authentication for agent host tunnels. When disabled, only GitHub authentication is used."), + type: 'boolean', + scope: ConfigurationScope.APPLICATION, + default: false, + tags: ['usesOnlineServices'], + }, + } +}); diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHostService.ts b/src/vs/workbench/contrib/chat/electron-browser/tunnelHostService.ts similarity index 87% rename from src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHostService.ts rename to src/vs/workbench/contrib/chat/electron-browser/tunnelHostService.ts index 01f5824fee0c0..21130dcfdee6d 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHostService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/tunnelHostService.ts @@ -3,15 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { joinPath } from '../../../../base/common/resources.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; -import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; import { localize } from '../../../../nls.js'; import { ITunnelAgentHostHostingService, @@ -22,6 +13,15 @@ import { } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; +import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; import { ITunnelHostService } from '../common/tunnelHost.js'; export const CONFIGURATION_KEY_MICROSOFT_AUTH = 'remote.tunnels.access.enableMicrosoftAuth'; @@ -54,8 +54,6 @@ export class TunnelHostService extends Disposable implements ITunnelHostService ) { super(); - // Register a renderer-side logger so that the output channel - // created in the shared process is visible in the workbench UI this._logger = this._register(loggerService.createLogger( joinPath(environmentService.logsHome, `${TUNNEL_HOST_LOG_ID}.log`), { id: TUNNEL_HOST_LOG_ID, name: localize('tunnelHost.outputChannel', "Remote Connections") }, @@ -65,14 +63,12 @@ export class TunnelHostService extends Disposable implements ITunnelHostService sharedProcessService.getChannel(TUNNEL_HOST_CHANNEL), ); - // Listen for status changes from the shared process this._register(this._mainService.onDidChangeStatus((status: TunnelHostStatus) => { this._isSharing = status.active; this._sharingInfo = status.active ? status.info : undefined; this._onDidChangeStatus.fire(); })); - // Restore status on construction this._mainService.getStatus().then(status => { this._isSharing = status.active; this._sharingInfo = status.active ? status.info : undefined; @@ -101,11 +97,11 @@ export class TunnelHostService extends Disposable implements ITunnelHostService try { const auth = await this._getToken(false); if (!auth) { - this._logger.warn(`No auth token available for tunnel hosting`); + this._logger.warn('No auth token available for tunnel hosting'); throw new Error(localize('tunnelHost.noAuth', "No authentication token available. Please sign in and try again.")); } - this._logger.info(`Starting tunnel hosting...`); + this._logger.info('Starting tunnel hosting...'); const socketInfo = await this._agentHostService.startWebSocketServer(); const info = await this._mainService.startHosting(auth.token, auth.provider, socketInfo); @@ -118,15 +114,13 @@ export class TunnelHostService extends Disposable implements ITunnelHostService } async stopSharing(): Promise { - this._logger.info(`Stopping tunnel hosting...`); + this._logger.info('Stopping tunnel hosting...'); await this._mainService.stopHosting(); this._isSharing = false; this._sharingInfo = undefined; this._onDidChangeStatus.fire(); } - // ---- Auth helpers (reused from TunnelAgentHostService) ------------------- - private _getEnabledProviders(): readonly ('github' | 'microsoft')[] { const microsoftEnabled = this._configurationService.getValue(CONFIGURATION_KEY_MICROSOFT_AUTH); return microsoftEnabled ? ['microsoft', 'github'] : ['github']; @@ -135,7 +129,6 @@ export class TunnelHostService extends Disposable implements ITunnelHostService private async _getToken(silent: boolean): Promise<{ token: string; provider: 'github' | 'microsoft' } | undefined> { const enabledProviders = this._getEnabledProviders(); - // Try the last known provider first if (this._lastAuthProvider && enabledProviders.includes(this._lastAuthProvider)) { const result = await this._getTokenForProvider(this._lastAuthProvider, silent); if (result) { @@ -143,7 +136,6 @@ export class TunnelHostService extends Disposable implements ITunnelHostService } } - // Try enabled providers silently for (const provider of enabledProviders) { if (provider === this._lastAuthProvider) { continue; @@ -154,7 +146,6 @@ export class TunnelHostService extends Disposable implements ITunnelHostService } } - // If not silent, try interactively with each enabled provider if (!silent) { for (const provider of enabledProviders) { const result = await this._getTokenForProvider(provider, false); @@ -182,10 +173,8 @@ export class TunnelHostService extends Disposable implements ITunnelHostService } try { - // Try exact scope match first let sessions = await this._authenticationService.getSessions(provider, scopes, {}, true); - // Fall back: find any session whose scopes are a superset if (sessions.length === 0) { const allSessions = await this._authenticationService.getSessions(provider, undefined, {}, true); const requestedSet = new Set(scopes); @@ -213,7 +202,6 @@ export class TunnelHostService extends Disposable implements ITunnelHostService } } - // Interactive fallback: create a new session if (sessions.length === 0 && !silent) { const session = await this._authenticationService.createSession(provider, scopes, { activateImmediate: true }); sessions = [session]; @@ -232,11 +220,4 @@ export class TunnelHostService extends Disposable implements ITunnelHostService return undefined; } - override dispose(): void { - // Best-effort cleanup — stop hosting when the window closes - if (this._isSharing) { - this.stopSharing().catch(() => { /* ignore */ }); - } - super.dispose(); - } } diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 2fc6c8a3ff0fb..b94b0d6ef90f4 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -184,6 +184,7 @@ import './contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; // Chat import './contrib/chat/electron-browser/chat.contribution.js'; +import './contrib/chat/electron-browser/tunnelHost.contribution.js'; // Copilot Voice import './contrib/agentsVoice/electron-browser/agentsVoiceNativeCommands.js'; From 636160716243ff2d656ba11fb93fea0090353d6c Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:32:24 -0700 Subject: [PATCH 053/696] Use provider-specific icons for agent host sessions (#322597) The agent host session handler and list controller hardcoded `Codicon.copilot` for the chat session item icon and the dynamic agent's themeIcon, so Claude and Codex agent host sessions showed the Copilot icon in the chat session list and the conversation header (including its hover), even though the picker and the Agent Sessions view already resolved the correct per-provider icon. Derive the icon from the session type via `getAgentSessionProviderIcon` instead, so each provider gets its own glyph (claude/openai/copilot) and unknown types fall back to `Codicon.extensions`, consistent with the other surfaces. Co-authored-by: Claude Opus 4.8 --- .../agentSessions/agentHost/agentHostSessionHandler.ts | 6 +++--- .../agentHost/agentHostSessionListController.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index e3e550cd97d33..b29925b39a92c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -5,7 +5,6 @@ import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; @@ -64,6 +63,7 @@ import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService, IToolInvocation, IToolResult, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { IChatWidgetService } from '../../chat.js'; +import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; @@ -837,7 +837,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC isDefault: false, isDynamic: true, isCore: true, - metadata: { themeIcon: Codicon.copilot }, + metadata: { themeIcon: getAgentSessionProviderIcon(this._config.sessionType) }, slashCommands: [], locations: [ChatAgentLocation.Chat], modes: [ChatModeKind.Agent], @@ -2830,7 +2830,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return { resource: forkedResource, label: forkedLabel, - iconPath: Codicon.copilot, + iconPath: getAgentSessionProviderIcon(this._config.sessionType), timing: { created: now, lastRequestStarted: now, lastRequestEnded: now }, }; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index a6bd68095d63f..a680d37d36956 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; @@ -16,6 +15,7 @@ import type { INotification } from '../../../../../../platform/agentHost/common/ import { SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { ChatSessionStatus, IChatNewSessionRequest, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; +import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; @@ -373,7 +373,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), label: opts.title || `Session ${rawId.substring(0, 8)}`, description, - iconPath: Codicon.copilot, + iconPath: getAgentSessionProviderIcon(this._sessionType), status: mapSessionStatus(opts.status), archived: opts.status !== undefined && (opts.status & SessionStatus.IsArchived) === SessionStatus.IsArchived, metadata: this._buildMetadata(opts.workingDirectory), From 9a2c9f33e751370451c565f4cc552a6afff796a7 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:37:25 +0200 Subject: [PATCH 054/696] sessions: add CI failures and created-comments banners above the chat input (#322578) * sessions: add CI failures and created-comments banners above the chat input Add two dismissible banners that render directly above the active session's chat input in the Agents window: - A CI failures banner (orange accent) shown when the active session has at least one failed CI check. It reads "X of N checks failed" (ellipsized) with a floating right-aligned button bar: Fix Checks (same as the Changes view fix-ci action) and Reveal Checks (opens the Changes view and expands + focuses the CI checks section). - A created-comments banner (neutral accent) shown when the active session has reviewable (PR/Agent review) comments still in the Created state. It reads "N comments" with Address Comments (sends /act-on-feedback) and Reveal Comments (opens the first comment in the editor). Each banner has its own dismiss (x) that permanently hides it for that session, persisted across reloads via the storage service. The presentational card is a self-contained SessionInputBannerWidget so it can be rendered in a Component Explorer fixture; the reactive SessionInputBanners host composes it and the chat view mounts it above the input via the public IChatWidget.inputPart.element API. Also adds CIStatusWidget.expand()/focus(), ChangesViewPane.revealChecks(), and a Reveal Checks command, plus Component Explorer fixtures for the banners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: register sessionInputBanners in i18n.resources.json Fixes the Compile & Hygiene CI failure: the new contrib uses localize() so its folder must be listed for translation extraction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address PR feedback on input banner widget - Use the shared Button widget for banner action buttons so theming, focus, keyboard/touch, and accessibility stay consistent (primary = button colors, secondary = ghost), mirroring the chat input notification widget. - Make the ThemeIcon import type-only. - Use the --vscode-strokeThickness token for the 1px border and focus outlines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: make the Fix Checks button orange to match the CI banner accent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: give secondary banner buttons a visible border Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: label comments banner by kind (PR / agent / mixed) Show 'N PR comments' when all counted comments are PR reviews, 'N agent comments' when all are agent reviews, and 'N comments' for a mix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/i18n.resources.json | 4 + .../contrib/changes/browser/changesView.ts | 37 +++ .../contrib/changes/browser/checksActions.ts | 8 +- .../contrib/changes/browser/checksWidget.ts | 28 ++ .../sessions/contrib/chat/browser/chatView.ts | 25 ++ .../browser/media/sessionInputBanners.css | 126 ++++++++ .../browser/sessionInputBannerWidget.ts | 98 +++++++ .../browser/sessionInputBanners.ts | 273 ++++++++++++++++++ .../browser/sessionInputBanners.fixture.ts | 89 ++++++ 9 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css create mode 100644 src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts create mode 100644 src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts create mode 100644 src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index e4a62e2c468f6..a669e562d706b 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -720,6 +720,10 @@ "name": "vs/sessions/contrib/sessions", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/sessionInputBanners", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/terminal", "project": "vscode-sessions" diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index a64bc4a687ae6..e4d93df7d4738 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -68,6 +68,7 @@ import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js'; import { logChangesViewFileSelect, logChangesViewVersionModeChange, logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; import { ChecksViewModel } from './checksViewModel.js'; +import { REVEAL_CI_CHECKS_COMMAND_ID } from './checksActions.js'; // eslint-disable-next-line local/code-import-patterns -- TODO: move skill button constants out of providers import { AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, isAgentHostSkillButtonId } from '../../providers/agentHost/browser/agentHostSkillButtons.js'; import { ActiveSessionContextKeys, CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, IsolationMode, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; @@ -1125,6 +1126,18 @@ export class ChangesViewPane extends ViewPane { await this._openMultiFileDiffEditor(resource); } + /** + * Reveal the CI checks section: expand it if collapsed and move keyboard + * focus into it. No-op when there are no checks to show. + */ + revealChecks(): void { + if (!this.ciStatusWidget || !this.ciStatusWidget.visible) { + return; + } + this.ciStatusWidget.expand(); + this.ciStatusWidget.focus(); + } + private async _openFileItem(item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean): Promise { const { uri: modifiedFileUri, originalUri, isDeletion } = item; const currentIndex = items.indexOf(item); @@ -1497,6 +1510,30 @@ class ChangesDiffStatsAction extends Action2 { } registerAction2(ChangesDiffStatsAction); +/** + * Opens the Changes view and reveals (expands + focuses) the CI checks section. + * Used by the CI failures banner above the chat input. + */ +class RevealCIChecksAction extends Action2 { + static readonly ID = REVEAL_CI_CHECKS_COMMAND_ID; + + constructor() { + super({ + id: RevealCIChecksAction.ID, + title: localize2('revealChecks', 'Reveal Checks'), + category: CHAT_CATEGORY, + f1: false, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = await viewsService.openView(CHANGES_VIEW_ID, true); + view?.revealChecks(); + } +} +registerAction2(RevealCIChecksAction); + class ChangesDiffStatsActionItem extends ActionViewItem { private readonly diffStatsObs: IObservable<{ files: number; insertions: number; deletions: number } | undefined>; diff --git a/src/vs/sessions/contrib/changes/browser/checksActions.ts b/src/vs/sessions/contrib/changes/browser/checksActions.ts index 4f62e606a607b..aea4caf1021ee 100644 --- a/src/vs/sessions/contrib/changes/browser/checksActions.ts +++ b/src/vs/sessions/contrib/changes/browser/checksActions.ts @@ -21,6 +21,12 @@ import { GitHubCheckConclusion, GitHubCheckStatus, IGitHubCICheck } from '../../ import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; export const hasActiveSessionFailedCIChecks = new RawContextKey('sessions.hasActiveSessionFailedCIChecks', false); +/** Command that sends the `fix-ci` prompt for the active session's failed checks. */ +export const FIX_CI_CHECKS_COMMAND_ID = 'sessions.action.fixCIChecks'; + +/** Command that opens the Changes view and reveals (expands + focuses) the CI checks section. */ +export const REVEAL_CI_CHECKS_COMMAND_ID = 'sessions.action.revealCIChecks'; + /** Slash command that invokes the built-in `fix-ci` skill. */ const FIX_CI_QUERY = '/fix-ci'; @@ -128,7 +134,7 @@ class ActiveSessionFailedCIChecksContextContribution extends Disposable implemen class FixCIChecksAction extends Action2 { - static readonly ID = 'sessions.action.fixCIChecks'; + static readonly ID = FIX_CI_CHECKS_COMMAND_ID; constructor() { super({ diff --git a/src/vs/sessions/contrib/changes/browser/checksWidget.ts b/src/vs/sessions/contrib/changes/browser/checksWidget.ts index cdda8dac81ed3..0f0d995ff2204 100644 --- a/src/vs/sessions/contrib/changes/browser/checksWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/checksWidget.ts @@ -395,6 +395,34 @@ export class CIStatusWidget extends Disposable { this._onDidChangeHeight.fire(); } + /** + * Expand the body if it is currently collapsed, notifying listeners so the + * parent pane restores its size. No-op when already expanded. + */ + expand(): void { + if (!this._collapsed) { + return; + } + this._setCollapsed(false); + this._onDidToggleCollapsed.fire(false); + this._onDidChangeHeight.fire(); + } + + /** + * Move keyboard focus into the checks list. Falls back to the header when + * the body is collapsed or there is nothing to focus. + */ + focus(): void { + if (this._collapsed || this._checkCount === 0) { + this._headerNode.focus(); + return; + } + this._list.domFocus(); + if (this._list.length > 0 && this._list.getFocus().length === 0) { + this._list.setFocus([0]); + } + } + private _setCollapsed(collapsed: boolean): void { this._collapsed = collapsed; this._updateChevron(); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 3fe4667c177d3..0c3f050e1599e 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -22,6 +22,7 @@ import { IChat } from '../../../services/sessions/common/session.js'; import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; import { NewChatWidget } from './newChatWidget.js'; import { NewChatInSessionWidget } from './newChatInSessionWidget.js'; +import { SessionInputBanners } from '../../sessionInputBanners/browser/sessionInputBanners.js'; import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import { activeSessionViewBackground, activeSessionViewForeground, agentsPanelBackground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../../common/theme.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -101,6 +102,9 @@ export class ChatView extends AbstractChatView { private readonly _widget: ChatWidget; + /** Session banners (CI failures, created comments) shown above the chat input. */ + private readonly _banners: SessionInputBanners; + /** Reference to the loaded chat model; disposing releases the model. */ private readonly _modelRef = this._register(new MutableDisposable()); @@ -154,6 +158,11 @@ export class ChatView extends AbstractChatView { this._widget.render(this.element); this._widget.setVisible(true); + // Mount the session banners directly above the chat input. + this._banners = this._register(instantiationService.createInstance(SessionInputBanners)); + this._banners.setActive(this._isActive); + this._ensureBannersMounted(); + this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING)) { this._applyHistoryKey(); @@ -265,9 +274,24 @@ export class ChatView extends AbstractChatView { } protected override doLayout(width: number, height: number, _top: number, _left: number): void { + this._ensureBannersMounted(); this._widget.layout(height, width); } + /** + * Mounts the session banners as the first child of the chat input part, so + * they render above the input alongside the other above-input widgets + * (notifications, goal banner, etc.). Idempotent — re-runs cheaply on layout + * to recover if the chat widget rebuilds its input part DOM. + */ + private _ensureBannersMounted(): void { + const inputPartElement = this._widget.inputPart.element; + const node = this._banners.domNode; + if (inputPartElement.firstChild !== node) { + inputPartElement.insertBefore(node, inputPartElement.firstChild); + } + } + override focus(): void { this._widget.focusInput(); } @@ -283,6 +307,7 @@ export class ChatView extends AbstractChatView { return; } this._isActive = active; + this._banners.setActive(active); this._widget.setStyles(this._buildStyles(active)); } } diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css new file mode 100644 index 0000000000000..84b3d5b351504 --- /dev/null +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* Host: a vertical stack of session banners sitting directly above the chat input. */ +.session-input-banners { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Collapse the host entirely when there is nothing to show so it adds no spacing. */ +.session-input-banners:not(:has(.session-input-banner)) { + display: none; +} + +/* A single banner card. */ +.session-input-banner { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + box-sizing: border-box; + padding: 4px 6px 4px 10px; + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); + border-radius: var(--vscode-cornerRadius-small, 4px); + background-color: color-mix(in srgb, var(--vscode-focusBorder) 6%, var(--vscode-editorWidget-background)); + font-size: var(--vscode-chat-font-size-body-s); + font-family: var(--vscode-chat-font-family, inherit); +} + +/* Orange accent variant (used by the CI failures banner). */ +.session-input-banner.accent-orange { + border-color: var(--vscode-charts-orange); + background-color: color-mix(in srgb, var(--vscode-charts-orange) 6%, var(--vscode-editorWidget-background)); +} + +/* Leading icon. */ +.session-input-banner .session-input-banner-icon { + flex-shrink: 0; + display: flex; + align-items: center; + color: var(--vscode-icon-foreground); +} + +.session-input-banner.accent-orange .session-input-banner-icon { + color: var(--vscode-charts-orange); +} + +/* Text — single line, ellipsized when it does not fit next to the actions. */ +.session-input-banner .session-input-banner-text { + flex: 1 1 auto; + min-width: 0; + color: var(--vscode-foreground); + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Floating, right-aligned button bar. */ +.session-input-banner .session-input-banner-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; +} + +.session-input-banner .session-input-banner-action { + font-size: var(--vscode-agents-fontSize-label2); + padding: 0 8px; + min-width: unset; + width: auto; + height: 20px; + white-space: nowrap; +} + +/* Outlined ghost style for secondary action buttons (e.g. "Reveal Checks"). */ +.session-input-banner .session-input-banner-action.secondary { + border: var(--vscode-strokeThickness) solid var(--vscode-button-secondaryBackground, var(--vscode-button-border, var(--vscode-contrastBorder))); + background: transparent; + color: var(--vscode-foreground); + opacity: 0.8; +} + +.session-input-banner .session-input-banner-action.secondary:hover { + background: var(--vscode-toolbar-hoverBackground); + opacity: 1; +} + +/* In the orange CI banner, the primary action matches the orange accent. */ +.session-input-banner.accent-orange .session-input-banner-action:not(.secondary) { + background: var(--vscode-charts-orange); + color: var(--vscode-button-foreground); +} + +.session-input-banner.accent-orange .session-input-banner-action:not(.secondary):hover { + background: color-mix(in srgb, var(--vscode-charts-orange) 88%, black); +} + +/* Dismiss (x) button. */ +.session-input-banner .session-input-banner-dismiss { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + border: none; + border-radius: var(--vscode-cornerRadius-small, 4px); + cursor: pointer; + color: var(--vscode-icon-foreground); + background: transparent; + outline: none; +} + +.session-input-banner .session-input-banner-dismiss:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.session-input-banner .session-input-banner-dismiss:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts new file mode 100644 index 0000000000000..9a30cc4928e9c --- /dev/null +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionInputBanners.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import type { ThemeIcon } from '../../../../base/common/themables.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; + +export interface ISessionInputBannerAction { + readonly label: string; + /** Renders the action with the prominent button colors. */ + readonly primary?: boolean; + run(): void; +} + +export interface ISessionInputBanner { + readonly icon: ThemeIcon; + /** Use the orange accent (border + icon) reserved for CI failures. */ + readonly accent: boolean; + /** Single-line text; ellipsized when it does not fit next to the actions. */ + readonly text: string; + readonly ariaLabel: string; + readonly actions: readonly ISessionInputBannerAction[]; + readonly dismissTooltip: string; + dismiss(): void; +} + +/** + * A single, self-contained banner card rendered directly above the chat input. + * Shows a leading icon, an ellipsized line of text, a floating right-aligned + * button bar, and a dismiss (x) button. Purely presentational — all behavior is + * provided by the {@link ISessionInputBanner} passed in. + */ +export class SessionInputBannerWidget extends Disposable { + + readonly domNode: HTMLElement; + + constructor( + banner: ISessionInputBanner, + @IHoverService private readonly hoverService: IHoverService, + ) { + super(); + + this.domNode = dom.$('.session-input-banner'); + this.domNode.classList.toggle('accent-orange', banner.accent); + this.domNode.setAttribute('role', 'status'); + this.domNode.setAttribute('aria-label', banner.ariaLabel); + + const icon = dom.append(this.domNode, dom.$('.session-input-banner-icon')); + icon.appendChild(renderIcon(banner.icon)); + + const textEl = dom.append(this.domNode, dom.$('span.session-input-banner-text')); + textEl.textContent = banner.text; + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), textEl, banner.text)); + + const actions = dom.append(this.domNode, dom.$('.session-input-banner-actions')); + for (const action of banner.actions) { + // Primary uses the prominent button colors; secondary renders as a + // ghost button (colors overridden to undefined so the CSS ghost + // styles apply), mirroring the chat input notification widget. + const button = this._register(new Button(actions, { + ...defaultButtonStyles, + ...(action.primary ? {} : { + buttonBackground: undefined, + buttonHoverBackground: undefined, + buttonForeground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + }), + secondary: !action.primary, + })); + button.element.classList.add('session-input-banner-action'); + button.label = action.label; + button.element.ariaLabel = `${banner.ariaLabel} ${action.label}`; + this._register(button.onDidClick(() => action.run())); + } + + const dismiss = dom.append(this.domNode, dom.$('button.session-input-banner-dismiss')) as HTMLButtonElement; + dismiss.type = 'button'; + dismiss.setAttribute('aria-label', banner.dismissTooltip); + dismiss.appendChild(renderIcon(Codicon.close)); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), dismiss, banner.dismissTooltip)); + this._register(dom.addDisposableListener(dismiss, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + banner.dismiss(); + })); + } +} diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts new file mode 100644 index 0000000000000..0b6bb42bc5b9a --- /dev/null +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -0,0 +1,273 @@ +/*--------------------------------------------------------------------------------------------- + * 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, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { autorun, derived, IObservable, ISettableObservable, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IInstantiationService } 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 { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { FIX_CI_CHECKS_COMMAND_ID, getFailedChecks, REVEAL_CI_CHECKS_COMMAND_ID } from '../../changes/browser/checksActions.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; +import { ISessionInputBanner, SessionInputBannerWidget } from './sessionInputBannerWidget.js'; + +/** Slash command run by the "Address comments" action. */ +const ACT_ON_FEEDBACK_QUERY = '/act-on-feedback'; + +/** Persisted set of session ids whose CI banner the user dismissed. */ +const STORAGE_KEY_CI_DISMISSED = 'sessions.inputBanners.ci.dismissed'; +/** Persisted set of session ids whose comments banner the user dismissed. */ +const STORAGE_KEY_COMMENTS_DISMISSED = 'sessions.inputBanners.comments.dismissed'; + +/** + * Feedback kinds that originate from a review the user triages (a pull request + * review or an in-product code review), matching the comments surfaced to the + * agent via the `viewUnreviewedComments` tool. + */ +const REVIEWABLE_KINDS: ReadonlySet = new Set([AgentFeedbackKind.PRReview, AgentFeedbackKind.AgentReview]); + +interface ICIBannerState { + readonly sessionId: string; + readonly failed: number; + readonly total: number; +} + +interface ICommentsBannerState { + readonly sessionId: string; + readonly sessionResource: URI; + readonly count: number; + /** Whether all counted comments are PR reviews, all are agent reviews, or mixed. */ + readonly kind: 'pr' | 'agent' | 'mixed'; + readonly firstCommentId: string; +} + +/** + * Hosts the banners that render directly above the active session's chat input: + * a CI failures banner and a created-comments banner. Each banner can be + * permanently dismissed per session. + * + * The host is owned by the session's chat view and only shows content while + * that view is the active session (driven via {@link setActive}); the CI model + * and feedback are read for the active session. + */ +export class SessionInputBanners extends Disposable { + + readonly domNode: HTMLElement; + + private readonly _ciSlot: HTMLElement; + private readonly _commentsSlot: HTMLElement; + + private readonly _ciContent = this._register(new MutableDisposable()); + private readonly _commentsContent = this._register(new MutableDisposable()); + + private readonly _active = observableValue(this, false); + + private readonly _ciDismissed = observableValue>(this, new Set()); + private readonly _commentsDismissed = observableValue>(this, new Set()); + + private _feedbackChanged!: IObservable; + + /** The session whose banners should be shown, or undefined when inactive. */ + private readonly _session = derived(this, reader => this._active.read(reader) ? this.sessionsService.activeSession.read(reader) : undefined); + + private readonly _ciState: IObservable = derived(this, reader => { + const session = this._session.read(reader); + if (!session || this._ciDismissed.read(reader).has(session.sessionId)) { + return undefined; + } + const ciModel = this.gitHubService.activeSessionPullRequestCIObs.read(reader); + if (!ciModel) { + return undefined; + } + const checks = ciModel.checks.read(reader); + const failed = getFailedChecks(checks).length; + if (failed === 0) { + return undefined; + } + return { sessionId: session.sessionId, failed, total: checks.length }; + }); + + private readonly _commentsState: IObservable = derived(this, reader => { + const session = this._session.read(reader); + if (!session || this._commentsDismissed.read(reader).has(session.sessionId)) { + return undefined; + } + this._feedbackChanged.read(reader); + const created = this.feedbackService.getFeedback(session.resource) + .filter(item => item.state === AgentFeedbackState.Created && REVIEWABLE_KINDS.has(item.kind)); + if (created.length === 0) { + return undefined; + } + const allPR = created.every(item => item.kind === AgentFeedbackKind.PRReview); + const allAgent = created.every(item => item.kind === AgentFeedbackKind.AgentReview); + const kind = allPR ? 'pr' : allAgent ? 'agent' : 'mixed'; + return { sessionId: session.sessionId, sessionResource: session.resource, count: created.length, kind, firstCommentId: created[0].id }; + }); + + constructor( + @ISessionsService private readonly sessionsService: ISessionsService, + @IGitHubService private readonly gitHubService: IGitHubService, + @IAgentFeedbackService private readonly feedbackService: IAgentFeedbackService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @ICommandService private readonly commandService: ICommandService, + @IStorageService private readonly storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.domNode = dom.$('.session-input-banners'); + this._ciSlot = dom.append(this.domNode, dom.$('.session-input-banner-slot')); + this._commentsSlot = dom.append(this.domNode, dom.$('.session-input-banner-slot')); + + this._feedbackChanged = observableSignalFromEvent(this, this.feedbackService.onDidChangeFeedback); + + // Load persisted dismissal state and keep it in sync with other windows/profiles. + this._ciDismissed.set(this._readDismissed(STORAGE_KEY_CI_DISMISSED), undefined); + this._commentsDismissed.set(this._readDismissed(STORAGE_KEY_COMMENTS_DISMISSED), undefined); + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, STORAGE_KEY_CI_DISMISSED, this._store)(() => { + this._ciDismissed.set(this._readDismissed(STORAGE_KEY_CI_DISMISSED), undefined); + })); + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, STORAGE_KEY_COMMENTS_DISMISSED, this._store)(() => { + this._commentsDismissed.set(this._readDismissed(STORAGE_KEY_COMMENTS_DISMISSED), undefined); + })); + + this._register(autorun(reader => this._renderCIBanner(this._ciState.read(reader)))); + this._register(autorun(reader => this._renderCommentsBanner(this._commentsState.read(reader)))); + } + + /** Marks whether the owning chat view is the active session. */ + setActive(active: boolean): void { + this._active.set(active, undefined); + } + + private _renderCIBanner(state: ICIBannerState | undefined): void { + const store = this._ciContent.value = new DisposableStore(); + dom.clearNode(this._ciSlot); + if (!state) { + return; + } + + const text = state.total === 1 + ? localize('ci.oneCheckFailed', "1 check failed") + : localize('ci.checksFailed', "{0} of {1} checks failed", state.failed, state.total); + + this._renderBanner(this._ciSlot, store, { + icon: Codicon.warning, + accent: true, + text, + ariaLabel: text, + dismissTooltip: localize('ci.dismiss', "Hide for this session"), + actions: [ + { + label: localize('ci.fixChecks', "Fix Checks"), + primary: true, + run: () => this._executeCommand(FIX_CI_CHECKS_COMMAND_ID), + }, + { + label: localize('ci.revealChecks', "Reveal Checks"), + run: () => this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID), + }, + ], + dismiss: () => this._dismiss(STORAGE_KEY_CI_DISMISSED, this._ciDismissed, state.sessionId), + }); + } + + private _renderCommentsBanner(state: ICommentsBannerState | undefined): void { + const store = this._commentsContent.value = new DisposableStore(); + dom.clearNode(this._commentsSlot); + if (!state) { + return; + } + + const text = this._commentsBannerText(state.kind, state.count); + + this._renderBanner(this._commentsSlot, store, { + icon: Codicon.commentDiscussion, + accent: false, + text, + ariaLabel: text, + dismissTooltip: localize('comments.dismiss', "Hide for this session"), + actions: [ + { + label: localize('comments.address', "Address Comments"), + primary: true, + run: () => this._addressComments(state.sessionResource), + }, + { + label: localize('comments.reveal', "Reveal Comments"), + run: () => this._revealComment(state.sessionResource, state.firstCommentId), + }, + ], + dismiss: () => this._dismiss(STORAGE_KEY_COMMENTS_DISMISSED, this._commentsDismissed, state.sessionId), + }); + } + + private _renderBanner(container: HTMLElement, store: DisposableStore, banner: ISessionInputBanner): void { + const widget = store.add(this.instantiationService.createInstance(SessionInputBannerWidget, banner)); + container.appendChild(widget.domNode); + } + + private _commentsBannerText(kind: 'pr' | 'agent' | 'mixed', count: number): string { + switch (kind) { + case 'pr': + return count === 1 + ? localize('comments.pr.one', "1 PR comment") + : localize('comments.pr.many', "{0} PR comments", count); + case 'agent': + return count === 1 + ? localize('comments.agent.one', "1 agent comment") + : localize('comments.agent.many', "{0} agent comments", count); + case 'mixed': + return count === 1 + ? localize('comments.one', "1 comment") + : localize('comments.many', "{0} comments", count); + } + } + + private _executeCommand(commandId: string): void { + this.commandService.executeCommand(commandId).catch(err => this.logService.error('[SessionInputBanners] command failed', commandId, err)); + } + + private _addressComments(sessionResource: URI): void { + const widget = this.chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + this.logService.error('[SessionInputBanners] Cannot address comments: no chat widget for session', sessionResource.toString()); + return; + } + widget.acceptInput(ACT_ON_FEEDBACK_QUERY).catch(err => this.logService.error('[SessionInputBanners] Failed to send act-on-feedback', err)); + } + + private _revealComment(sessionResource: URI, commentId: string): void { + this.feedbackService.revealFeedback(sessionResource, commentId).catch(err => this.logService.error('[SessionInputBanners] Failed to reveal comment', err)); + } + + private _dismiss(storageKey: string, observable: ISettableObservable>, sessionId: string): void { + const next = new Set(observable.get()); + next.add(sessionId); + this.storageService.store(storageKey, JSON.stringify([...next]), StorageScope.PROFILE, StorageTarget.USER); + observable.set(next, undefined); + } + + private _readDismissed(storageKey: string): ReadonlySet { + const raw = this.storageService.get(storageKey, StorageScope.PROFILE); + if (!raw) { + return new Set(); + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? new Set(parsed.filter((id): id is string => typeof id === 'string')) : new Set(); + } catch { + return new Set(); + } + } +} diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts new file mode 100644 index 0000000000000..d86ed96eb9769 --- /dev/null +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ISessionInputBanner, SessionInputBannerWidget } from '../../browser/sessionInputBannerWidget.js'; + +export default defineThemedFixtureGroup({ path: 'sessions/inputBanners/' }, { + CIFailures: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [ciBanner(2, 5)]), + }), + + Comments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [commentsBanner(3, 'mixed')]), + }), + + PRComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [commentsBanner(2, 'pr')]), + }), + + AgentComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [commentsBanner(4, 'agent')]), + }), + + Both: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [ciBanner(1, 4), commentsBanner(1, 'mixed')]), + }), + + LongTextEllipsis: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [ciBanner(12, 18)], 360), + }), +}); + +function ciBanner(failed: number, total: number): ISessionInputBanner { + const text = total === 1 ? '1 check failed' : `${failed} of ${total} checks failed`; + return { + icon: Codicon.warning, + accent: true, + text, + ariaLabel: text, + dismissTooltip: 'Hide for this session', + actions: [ + { label: 'Fix Checks', primary: true, run: () => console.log('Fix Checks') }, + { label: 'Reveal Checks', run: () => console.log('Reveal Checks') }, + ], + dismiss: () => console.log('Dismiss CI banner'), + }; +} + +function commentsBanner(count: number, kind: 'pr' | 'agent' | 'mixed'): ISessionInputBanner { + const noun = kind === 'pr' ? 'PR comment' : kind === 'agent' ? 'agent comment' : 'comment'; + const text = count === 1 ? `1 ${noun}` : `${count} ${noun}s`; + return { + icon: Codicon.commentDiscussion, + accent: false, + text, + ariaLabel: text, + dismissTooltip: 'Hide for this session', + actions: [ + { label: 'Address Comments', primary: true, run: () => console.log('Address Comments') }, + { label: 'Reveal Comments', run: () => console.log('Reveal Comments') }, + ], + dismiss: () => console.log('Dismiss comments banner'), + }; +} + +function renderBanners({ container, disposableStore, theme }: ComponentFixtureContext, banners: readonly ISessionInputBanner[], width = 480): void { + container.style.width = `${width}px`; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '4px'; + container.style.padding = '8px'; + container.style.backgroundColor = 'var(--vscode-editorWidget-background)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + for (const banner of banners) { + const widget = disposableStore.add(instantiationService.createInstance(SessionInputBannerWidget, banner)); + container.appendChild(widget.domNode); + } +} From d2892d5d78c807971730e3976d893c13b5224051 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:38:00 +0200 Subject: [PATCH 055/696] sessions: contribute PR pill as session header meta toolbar action (#322581) * sessions: contribute PR pill as session header meta toolbar action The session header pull request pill was rendered as bespoke DOM directly in sessionHeader.ts. Instead contribute it as an action into the existing Menus.SessionHeaderMeta toolbar, mirroring how the changes view contributes the diff-stats action. - Add OpenPullRequestAction + OpenPullRequestActionViewItem (renders the # pill, opens the PR on GitHub) in contrib/github/browser/pullRequestActions.ts, registered via IActionViewItemService. - Gate it on a new per-view SessionHasPullRequestContext key, set by SessionView from the session's GitHub info (parallel to SessionHasChangesContext). - Remove the manual PR pill DOM, fields, handlers and IOpenerService/URI usage from sessionHeader.ts; the meta toolbar now owns both the diff-stats and PR pills. - Share the pill CSS between the diff-stats and PR action view items. - Replace the header fixture's PR-pill variants with a dedicated openPullRequest fixture for the new action view item. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: document PR pill in Agents window accessibility help Add a line to the SessionsChat accessibility help dialog describing the session header pull request pill and referencing its command keybinding placeholder, mirroring the existing diff-stats entry so screen reader users can discover it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: register vs/sessions/contrib/github for i18n The new pullRequestActions.ts introduces the first localized strings under vs/sessions/contrib/github. Add the module to build/lib/i18n.resources.json so the code-translation-remind eslint rule passes (it was failing Compile & Hygiene). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fixtures: render PR + diff pills in session header fixture The session header fixture rendered the real SessionHeader, but its meta toolbar (Menus.SessionHeaderMeta) was backed by the empty TestMenuService / NullActionViewItemService, so the contributed pull request and diff-stats pills never appeared. Wire the fixture's meta toolbar through the production code path: use FixtureMenuService for IMenuService, a small Map-backed IActionViewItemService, and a bound ISessionContext, then register the real OpenPullRequestActionViewItem / ViewAllChangesActionViewItem factories and contribute the matching menu items (gated on the mock session having a PR / changes). The header now shows 'folder # +X -Y' as in production. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/i18n.resources.json | 4 + src/vs/sessions/LAYOUT.md | 2 +- .../browser/parts/media/chatCompositeBar.css | 35 +--- .../sessions/browser/parts/sessionHeader.ts | 70 +------- src/vs/sessions/browser/parts/sessionView.ts | 12 +- src/vs/sessions/common/contextkeys.ts | 1 + .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../github/browser/github.contribution.ts | 2 + .../github/browser/pullRequestActions.ts | 158 ++++++++++++++++++ .../sessions/openPullRequest.fixture.ts | 130 ++++++++++++++ .../sessions/sessionHeader.fixture.ts | 102 +++++++++-- 11 files changed, 410 insertions(+), 107 deletions(-) create mode 100644 src/vs/sessions/contrib/github/browser/pullRequestActions.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index a669e562d706b..914dc03fe146f 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -708,6 +708,10 @@ "name": "vs/sessions/contrib/git", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/github", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/logs", "project": "vscode-sessions" diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index b06254e929575..f4a541acb1ded 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -124,7 +124,7 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (workspace · diff stats), and the session toolbars (Run, Open in VS Code, New Chat). The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; the changes view contributes the diff stats as a clickable menu item (gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's changes, with its custom action view item registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. +- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (workspace · pull request · diff stats), and the session toolbars (Run, Open in VS Code, New Chat). The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; the changes view contributes the diff stats as a clickable menu item (gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's changes, with its custom action view item registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The GitHub contribution similarly contributes a `#` pull request pill (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with its custom action view item registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. - A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility is **sticky per opened session**: it appears once the session has more than one chat, or its single (default) chat carries a title that differs from the session title (so both independent titles stay visible), and from then on it stays shown for that session's lifetime — it is never hidden again when chats are later removed or renamed, keeping the experience consistent. A single chat that still inherits the session title (and never diverged) keeps the strip hidden. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index e47832775b010..c300a916864bc 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -198,13 +198,15 @@ height: 100%; } -.chat-composite-bar-meta-diff { +.chat-composite-bar-meta-diff, +.chat-composite-bar-meta-pr { display: inline-flex; align-items: center; flex-shrink: 0; } -.chat-composite-bar-meta-diff .action-label { +.chat-composite-bar-meta-diff .action-label, +.chat-composite-bar-meta-pr .action-label { display: inline-flex; align-items: center; gap: 6px; @@ -216,12 +218,14 @@ border-radius: var(--vscode-cornerRadius-small); } -.chat-composite-bar-meta-diff .action-label:hover { +.chat-composite-bar-meta-diff .action-label:hover, +.chat-composite-bar-meta-pr .action-label:hover { color: inherit; text-decoration: none; } -.chat-composite-bar-meta-diff .action-label:focus-visible { +.chat-composite-bar-meta-diff .action-label:focus-visible, +.chat-composite-bar-meta-pr .action-label:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; } @@ -234,29 +238,6 @@ color: var(--vscode-chat-linesRemovedForeground); } -/* Pull request pill rendered next to the workspace label in the meta row. */ -.chat-composite-bar-meta-pr { - display: inline-flex; - align-items: center; - flex-shrink: 0; - color: inherit; - font: inherit; - font-variant-numeric: tabular-nums; - cursor: pointer; - padding: 0 2px; - border-radius: var(--vscode-cornerRadius-small); -} - -.chat-composite-bar-meta-pr:hover { - color: inherit; - text-decoration: none; -} - -.chat-composite-bar-meta-pr:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; -} - /* Tabs row */ .chat-composite-bar-tabs-row { display: flex; diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index 9a1cac331bde2..6e0c64ced70fb 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -14,9 +14,7 @@ import { autorun, IObservable, IReader, observableSignalFromEvent } from '../../ import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { Codicon } from '../../../base/common/codicons.js'; import { ThemeIcon } from '../../../base/common/themables.js'; -import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { IOpenerService } from '../../../platform/opener/common/opener.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsListModelService } from '../../services/sessions/browser/sessionsListModelService.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; @@ -78,8 +76,6 @@ export class SessionHeader extends Disposable { private readonly _titleTextEl: HTMLElement; private readonly _metaRow: HTMLElement; private readonly _metaWorkspaceEl: HTMLElement; - private readonly _metaPrSeparatorEl: HTMLElement; - private readonly _metaPrEl: HTMLElement; private readonly _metaSeparatorEl: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; private readonly _metaToolbar: MenuWorkbenchToolBar; @@ -90,9 +86,6 @@ export class SessionHeader extends Disposable { private _renameInput: HTMLInputElement | undefined; private _session: IActiveSession | undefined; - /** The pull request currently surfaced in the meta row, used by the click handler. */ - private _currentPullRequestUri: URI | undefined; - private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -129,7 +122,6 @@ export class SessionHeader extends Disposable { @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, @ISessionsService private readonly _sessionsService: ISessionsService, @IHoverService private readonly _hoverService: IHoverService, - @IOpenerService private readonly _openerService: IOpenerService, ) { super(); @@ -190,8 +182,9 @@ export class SessionHeader extends Disposable { // Workspace label (rebuilt per session) followed by a separator and the // session header meta toolbar. Actions are contributed into the generic // Menus.SessionHeaderMeta menu; the changes view contributes the diff-stats - // action, rendered as a clickable menu item that opens the multi-file diff - // editor. + // action (opens the multi-file diff editor) and the GitHub contribution + // contributes the pull request pill (opens the PR on GitHub), each rendered + // as a clickable menu item. this._metaWorkspaceEl = $('span.chat-composite-bar-meta-workspace'); this._metaRow.appendChild(this._metaWorkspaceEl); @@ -203,37 +196,6 @@ export class SessionHeader extends Disposable { () => this._buildWorkspaceHover(), )); - // Pull request pill: a `#` link rendered next to the workspace label - // when the session is associated with a GitHub pull request. Activating it - // opens the pull request on GitHub. The leading separator is shown only when - // both the workspace label and the pill are visible. - this._metaPrSeparatorEl = $('span.chat-composite-bar-meta-separator'); - this._metaRow.appendChild(this._metaPrSeparatorEl); - - this._metaPrEl = $('a.chat-composite-bar-meta-pr'); - this._metaPrEl.setAttribute('role', 'button'); - this._metaPrEl.tabIndex = 0; - this._metaRow.appendChild(this._metaPrEl); - - this._register(this._hoverService.setupManagedHover( - getDefaultHoverDelegate('element'), - this._metaPrEl, - () => this._currentPullRequestUri ? localize('agentSessions.openPullRequest', "Open Pull Request") : undefined, - )); - - this._register(addDisposableListener(this._metaPrEl, EventType.CLICK, e => { - e.preventDefault(); - e.stopPropagation(); - this._openPullRequest(); - })); - this._register(addStandardDisposableListener(this._metaPrEl, EventType.KEY_DOWN, (e: IKeyboardEvent) => { - if (e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space) { - e.preventDefault(); - e.stopPropagation(); - this._openPullRequest(); - } - })); - this._metaSeparatorEl = $('span.chat-composite-bar-meta-separator'); this._metaRow.appendChild(this._metaSeparatorEl); @@ -396,25 +358,14 @@ export class SessionHeader extends Disposable { } this._metaWorkspaceEl.style.display = hasWorkspace ? '' : 'none'; - // Pull request pill — surface `#` when the session's repository has an - // associated GitHub pull request. Clicking it opens the PR on GitHub. - const pullRequest = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest; - this._currentPullRequestUri = pullRequest?.uri; - const hasPullRequest = !!pullRequest; - if (hasPullRequest) { - reset(this._metaPrEl, $('span.chat-composite-bar-meta-pr-label', undefined, `#${pullRequest.number}`)); - } - this._metaPrEl.style.display = hasPullRequest ? '' : 'none'; - this._metaPrSeparatorEl.style.display = hasWorkspace && hasPullRequest ? '' : 'none'; - // Show the meta row separator/row based on whether the meta toolbar has any // contributed actions. Reading the signal re-runs this on menu changes. this._metaActionsSignal.read(reader); const hasMetaActions = !this._metaToolbar.isEmpty(); - this._metaSeparatorEl.style.display = (hasWorkspace || hasPullRequest) && hasMetaActions ? '' : 'none'; + this._metaSeparatorEl.style.display = hasWorkspace && hasMetaActions ? '' : 'none'; - this._metaRow.style.display = hasWorkspace || hasPullRequest || hasMetaActions ? '' : 'none'; + this._metaRow.style.display = hasWorkspace || hasMetaActions ? '' : 'none'; this._onDidChangeHeight.fire(); } @@ -449,17 +400,6 @@ export class SessionHeader extends Disposable { return { markdown: md, markdownNotSupportedFallback: fallbackLines.join('\n') }; } - /** - * Opens the pull request currently surfaced in the meta row on GitHub. - */ - private _openPullRequest(): void { - const uri = this._currentPullRequestUri; - if (!uri) { - return; - } - this._openerService.open(uri, { openExternal: true }).catch(onUnexpectedError); - } - private _setVisible(visible: boolean): void { const wasVisible = this._visible; this._visible = visible; diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index ffbd0a6647f08..91e702dc178eb 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -20,7 +20,7 @@ import { ChatCompositeBar } from './chatCompositeBar.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 { SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsReadContext, SessionIsStickyContext, SessionSupportsMultipleChatsContext, ChatSessionProviderIdContext, ChatSessionTypeContext, SessionHasChangesContext } from '../../common/contextkeys.js'; +import { SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsReadContext, SessionIsStickyContext, SessionSupportsMultipleChatsContext, ChatSessionProviderIdContext, ChatSessionTypeContext, SessionHasChangesContext, SessionHasPullRequestContext } from '../../common/contextkeys.js'; import { activeSessionViewBackground, activeSessionViewForeground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../common/theme.js'; import { SessionStatus } from '../../services/sessions/common/session.js'; @@ -80,6 +80,7 @@ export class SessionView extends Disposable implements ISerializableView { private readonly _chatSessionProviderIdKey: IContextKey; private readonly _chatSessionTypeKey: IContextKey; private readonly _sessionHasChangesKey: IContextKey; + private readonly _sessionHasPullRequestKey: IContextKey; /** Whether this view currently hosts the active session in the grid. */ private _isActive = true; @@ -105,6 +106,7 @@ export class SessionView extends Disposable implements ISerializableView { this._chatSessionProviderIdKey = ChatSessionProviderIdContext.bindTo(scopedContextKeyService); this._chatSessionTypeKey = ChatSessionTypeContext.bindTo(scopedContextKeyService); this._sessionHasChangesKey = SessionHasChangesContext.bindTo(scopedContextKeyService); + this._sessionHasPullRequestKey = SessionHasPullRequestContext.bindTo(scopedContextKeyService); // Scoped service exposing this view's session so toolbars and contributed // action view items (e.g. the changes diff stats in the header) can read it. @@ -201,6 +203,7 @@ export class SessionView extends Disposable implements ISerializableView { this._chatSessionProviderIdKey.set(''); this._chatSessionTypeKey.set(''); this._sessionHasChangesKey.set(false); + this._sessionHasPullRequestKey.set(false); return Disposable.None; } @@ -234,6 +237,13 @@ export class SessionView extends Disposable implements ISerializableView { this._sessionHasChangesKey.set(insertions > 0 || deletions > 0); })); + // Drives the visibility of the pull-request pill contributed by the GitHub + // contribution into the session header meta row. + disposables.add(autorun(reader => { + const pullRequest = session.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest; + this._sessionHasPullRequestKey.set(!!pullRequest); + })); + this._sessionSupportsMultipleChatsKey.set(session.capabilities.supportsMultipleChats); this._chatSessionProviderIdKey.set(session.providerId); this._chatSessionTypeKey.set(session.sessionType); diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 4df02733486a3..ffce0ab4c73b4 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -32,6 +32,7 @@ export const SessionSupportsMultipleChatsContext = new RawContextKey('s export const SessionIsReadContext = new RawContextKey('sessionIsRead', true, localize('sessionIsRead', "Whether the session has been marked as read")); export const SessionIsArchivedContext = new RawContextKey('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session is archived (marked as done)")); export const SessionHasChangesContext = new RawContextKey('sessionHasChanges', false, localize('sessionHasChanges', "Whether the session view's session has pending changes (insertions or deletions)")); +export const SessionHasPullRequestContext = new RawContextKey('sessionHasPullRequest', false, localize('sessionHasPullRequest', "Whether the session view's session is associated with a GitHub pull request")); //#endregion diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 6fe5337f62725..6f1458f244124 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -33,6 +33,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.navigateNextSession', "Navigate to the next session in the list{0}.", '')); content.push(localize('sessionsChat.changes', "Focus the Changes view{0}.", '')); content.push(localize('sessionsChat.viewAllChanges', "The session header shows the diff stats (lines added and removed) as a button. Activate it to open the multi-file diff editor for all of the session's changes{0}.", '')); + content.push(localize('sessionsChat.openPullRequest', "When the session is associated with a GitHub pull request, the session header shows the pull request number as a button. Activate it to open the pull request on GitHub{0}.", '')); content.push(localize('sessionsChat.filesView', "Focus the Files Explorer view{0}.", '')); content.push(localize('sessionsChat.sessionsView', "Focus the Chat Sessions view{0}.", '')); content.push(localize('sessionsChat.customizations', "Focus the Chat Customizations view{0}.", ``)); diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts index 509fb0db05075..fdd285dfaae0c 100644 --- a/src/vs/sessions/contrib/github/browser/github.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -17,6 +17,8 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { GitHubPullRequestState } from '../common/types.js'; import { GitHubService, IGitHubService } from './githubService.js'; +import './pullRequestActions.js'; + const TRACE_PREFIX = '[PR-ICON-TRACE]'; export class GitHubPullRequestPollingContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts new file mode 100644 index 0000000000000..49a85d47732cb --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, reset } from '../../../../base/browser/dom.js'; +import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionHasPullRequestContext } from '../../../common/contextkeys.js'; +import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; + +// --- Open Pull Request action + +class OpenPullRequestAction extends Action2 { + static readonly ID = 'workbench.agentSessions.action.openPullRequest'; + + constructor() { + super({ + id: OpenPullRequestAction.ID, + title: localize2('agentSessions.openPullRequest', 'Open Pull Request'), + f1: false, + // Pull request pill shown in the session header meta row + // (vs/sessions/browser/parts/sessionHeader.ts). Rendered with a + // custom action view item that shows the live `#` label. + menu: { + id: Menus.SessionHeaderMeta, + group: 'navigation', + order: 0, + when: SessionHasPullRequestContext + }, + }); + } + + override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise { + const openerService = accessor.get(IOpenerService); + const sessionsService = accessor.get(ISessionsService); + + // The clicked session is forwarded as the argument by the session header, + // which has already promoted it to be the active session. Fall back to the + // active session when invoked without an explicit argument. + const targetSession = session ?? sessionsService.activeSession.get(); + const pullRequestUri = getPullRequestUri(targetSession); + if (!pullRequestUri) { + return; + } + + await openerService.open(pullRequestUri, { openExternal: true }); + } +} +registerAction2(OpenPullRequestAction); + +function getPullRequestUri(session: IActiveSession | undefined): URI | undefined { + return session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get()?.pullRequest?.uri; +} + +// --- Open Pull Request action view item (session header pull request pill) + +/** + * Renders the session's associated pull request as a `#` pill, the label of the + * {@link OpenPullRequestAction} menu item contributed into {@link Menus.SessionHeaderMeta} + * (the session header meta row). Activating the item runs the action, which opens the pull + * request on GitHub. + * + * The pull request number is read from the {@link ISessionContext} so the correct per-session + * pull request is shown even when several session views are visible at once. + */ +export class OpenPullRequestActionViewItem extends ActionViewItem { + + private readonly _pullRequestNumberObs: IObservable; + + constructor( + action: MenuItemAction, + options: IActionViewItemOptions, + @ISessionContext sessionContext: ISessionContext, + ) { + super(undefined, action, { ...options, icon: false, label: true }); + + this._pullRequestNumberObs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + const session = sessionContext.session.read(reader); + const workspace = session?.workspace.read(reader); + return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest?.number; + }); + + this._register(autorun(reader => { + this._pullRequestNumberObs.read(reader); + this.updateLabel(); + this.updateTooltip(); + })); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-composite-bar-meta-pr'); + } + + protected override updateLabel(): void { + if (!this.label) { + return; + } + const number = this._pullRequestNumberObs.get(); + reset( + this.label, + $('span.chat-composite-bar-meta-pr-label', undefined, number !== undefined ? `#${number}` : ''), + ); + } + + protected override getTooltip(): string { + const number = this._pullRequestNumberObs.get(); + return number !== undefined + ? localize('agentSessions.openPullRequest.tooltipWithNumber', "Open Pull Request #{0}", number) + : localize('agentSessions.openPullRequest.tooltip', "Open Pull Request"); + } +} + +/** + * Registers the {@link OpenPullRequestActionViewItem} for the open-pull-request action in the + * session header meta toolbar. Registering it here (rather than in the core session header) + * keeps the rendering of the GitHub-owned action co-located with the action itself. + */ +class OpenPullRequestActionViewItemContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openPullRequestActionViewItem'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + + // The action view item service only notifies toolbars of a factory via + // the event passed to register(), not on registration itself. A session + // header restored with an existing pull request may create its meta + // toolbar before this contribution runs, so announce the factory once + // right after registering to make those toolbars re-render and pick it up. + const onDidRegister = this._register(new Emitter()); + this._register(actionViewItemService.register(Menus.SessionHeaderMeta, OpenPullRequestAction.ID, (action, options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(OpenPullRequestActionViewItem, action, options); + }, onDidRegister.event)); + onDidRegister.fire(); + } +} + +registerWorkbenchContribution2(OpenPullRequestActionViewItemContribution.ID, OpenPullRequestActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts new file mode 100644 index 0000000000000..13a6443671d2d --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { themeColorFromId } from '../../../../../base/common/themables.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubInfo, ISessionFolder, ISessionGitRepository, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; +// eslint-disable-next-line local/code-import-patterns +import { OpenPullRequestActionViewItem } from '../../../../../sessions/contrib/github/browser/pullRequestActions.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +function createMockWorkspace(pullRequest: IGitHubInfo['pullRequest']): ISessionWorkspace { + const root = URI.file('/home/user/projects/vscode'); + const gitHubInfo: IGitHubInfo = { owner: 'microsoft', repo: 'vscode', pullRequest }; + + const gitRepository: ISessionGitRepository = { + uri: root, + workTreeUri: undefined, + baseBranchName: 'main', + gitHubInfo: constObservable(gitHubInfo), + }; + + const folder: ISessionFolder = { + root, + workingDirectory: root, + name: 'vscode', + description: undefined, + gitRepository, + }; + + return { + uri: root, + label: 'vscode', + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +function createMockSession(pullRequest: IGitHubInfo['pullRequest']): IActiveSession { + return new class extends mock() { + override readonly resource = URI.parse('session:1'); + override readonly workspace: IObservable = observableValue('workspace', createMockWorkspace(pullRequest)); + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderPullRequestPill(ctx: ComponentFixtureContext, pullRequest: IGitHubInfo['pullRequest']): void { + const { container, disposableStore } = ctx; + + const session = observableValue('session', createMockSession(pullRequest)); + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + reg.defineInstance(ISessionContext, new SessionContext(session)); + }, + }); + + // Build the real menu item action the session header contributes, then + // render the production action view item against it. + const action = instantiationService.createInstance( + MenuItemAction, + { id: 'workbench.agentSessions.action.openPullRequest', title: 'Open Pull Request' }, + undefined, + undefined, + undefined, + undefined, + ); + + const item = disposableStore.add(instantiationService.createInstance(OpenPullRequestActionViewItem, action, {})); + + // Recreate the session header meta toolbar host so the inline-label styling + // (.chat-composite-bar-meta-toolbar) applies as in production. + const toolbar = document.createElement('div'); + toolbar.classList.add('chat-composite-bar-meta-toolbar'); + container.appendChild(toolbar); + item.render(toolbar); + + container.style.padding = '8px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; +} + +const openPr: IGitHubInfo['pullRequest'] = { + number: 12345, + uri: URI.parse('https://github.com/microsoft/vscode/pull/12345'), + icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }, +}; + +const draftPr: IGitHubInfo['pullRequest'] = { + number: 678, + uri: URI.parse('https://github.com/microsoft/vscode/pull/678'), + icon: { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }, +}; + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + OpenPullRequest_Open: defineComponentFixture({ + render: (ctx) => renderPullRequestPill(ctx, openPr), + }), + + OpenPullRequest_Draft: defineComponentFixture({ + render: (ctx) => renderPullRequestPill(ctx, draftPr), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts index 8efd0a9565f11..bc24841fb7e3e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts @@ -7,8 +7,11 @@ import { URI } from '../../../../../base/common/uri.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; import { Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IObservable, constObservable } from '../../../../../base/common/observable.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IActionViewItemService, IActionViewItemFactory } from '../../../../../platform/actions/browser/actionViewItemService.js'; // eslint-disable-next-line local/code-import-patterns import { IGitHubInfo, ISession, ISessionCapabilities, ISessionFileChange, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns @@ -18,12 +21,26 @@ import { ISessionsListModelService } from '../../../../../sessions/services/sess // eslint-disable-next-line local/code-import-patterns import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; // eslint-disable-next-line local/code-import-patterns +import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; +// eslint-disable-next-line local/code-import-patterns +import { Menus } from '../../../../../sessions/browser/menus.js'; +// eslint-disable-next-line local/code-import-patterns import { SessionHeader } from '../../../../../sessions/browser/parts/sessionHeader.js'; +// eslint-disable-next-line local/code-import-patterns +import { OpenPullRequestActionViewItem } from '../../../../../sessions/contrib/github/browser/pullRequestActions.js'; +// eslint-disable-next-line local/code-import-patterns +import { ViewAllChangesActionViewItem } from '../../../../../sessions/contrib/changes/browser/changesActions.js'; +import { FixtureMenuService } from '../chat/chatFixtureUtils.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; // eslint-disable-next-line local/code-import-patterns import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; +// The command ids the session header meta toolbar contributes (and renders as the +// pull request and diff-stats pills). Kept in sync with the production actions. +const OPEN_PULL_REQUEST_COMMAND_ID = 'workbench.agentSessions.action.openPullRequest'; +const VIEW_ALL_CHANGES_COMMAND_ID = 'workbench.agentSessions.action.viewChanges'; + // ============================================================================ // Mock helpers // ============================================================================ @@ -123,6 +140,35 @@ function createMockListModelService(): ISessionsListModelService { }(); } +// ============================================================================ +// Meta toolbar wiring +// ============================================================================ + +/** + * Minimal {@link IActionViewItemService} so the session header's meta toolbar can + * look up and render the contributed pull-request / diff-stats action view items + * exactly as it does in production. (The production registry class is not + * exported, so the fixture provides this small Map-backed equivalent.) + */ +class FixtureActionViewItemService implements IActionViewItemService { + declare readonly _serviceBrand: undefined; + readonly onDidChange = Event.None; + private readonly _providers = new Map(); + + register(menu: MenuId, commandOrSubmenu: string | MenuId, provider: IActionViewItemFactory): IDisposable { + this._providers.set(this._key(menu, commandOrSubmenu), provider); + return Disposable.None; + } + + lookUp(menu: MenuId, commandOrSubmenu: string | MenuId): IActionViewItemFactory | undefined { + return this._providers.get(this._key(menu, commandOrSubmenu)); + } + + private _key(menu: MenuId, commandOrSubmenu: string | MenuId): string { + return `${menu.id}/${commandOrSubmenu instanceof MenuId ? commandOrSubmenu.id : commandOrSubmenu}`; + } +} + // ============================================================================ // Render helper // ============================================================================ @@ -130,10 +176,19 @@ function createMockListModelService(): ISessionsListModelService { function renderHeader(ctx: ComponentFixtureContext, session: IActiveSession): void { const { container, disposableStore } = ctx; + const actionViewItemService = new FixtureActionViewItemService(); + const instantiationService = createEditorServices(disposableStore, { colorTheme: ctx.theme, additionalServices: (reg) => { registerWorkbenchServices(reg); + // Override the generic menu + action view item mocks so the header's + // meta toolbar resolves the contributed pills through the production + // code path. These must come AFTER registerWorkbenchServices because + // additionalServices registrations are last-wins. + reg.define(IMenuService, FixtureMenuService); + reg.defineInstance(IActionViewItemService, actionViewItemService); + reg.defineInstance(ISessionContext, new SessionContext(constObservable(session))); reg.defineInstance(ISessionsListModelService, createMockListModelService()); reg.defineInstance(ISessionsManagementService, new class extends mock() { override readonly onDidChangeSessions = Event.None; @@ -145,6 +200,25 @@ function renderHeader(ctx: ComponentFixtureContext, session: IActiveSession): vo }, }); + // Register the production action view items for the meta toolbar pills, then + // contribute the matching menu items — mirroring how the GitHub and changes + // contributions wire them up. This is done before the header is created so the + // meta toolbar renders them on first layout. + actionViewItemService.register(Menus.SessionHeaderMeta, OPEN_PULL_REQUEST_COMMAND_ID, (action, options, instaService) => + action instanceof MenuItemAction ? instaService.createInstance(OpenPullRequestActionViewItem, action, options) : undefined); + actionViewItemService.register(Menus.SessionHeaderMeta, VIEW_ALL_CHANGES_COMMAND_ID, (action, options, instaService) => + action instanceof MenuItemAction ? instaService.createInstance(ViewAllChangesActionViewItem, action, options) : undefined); + + const menuService = instantiationService.get(IMenuService) as FixtureMenuService; + const pullRequest = session.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get()?.pullRequest; + if (pullRequest) { + menuService.addItem(Menus.SessionHeaderMeta, { command: { id: OPEN_PULL_REQUEST_COMMAND_ID, title: 'Open Pull Request' }, group: 'navigation', order: 0 }); + } + const hasChanges = session.changes.get().some(change => change.insertions > 0 || change.deletions > 0); + if (hasChanges) { + menuService.addItem(Menus.SessionHeaderMeta, { command: { id: VIEW_ALL_CHANGES_COMMAND_ID, title: 'View All Changes' }, group: 'navigation', order: 1 }); + } + // The session header reads `--session-view-background/foreground` (set by the // hosting SessionView in production) for its surface colors, so mirror those // here against the agents-window panel background. @@ -164,19 +238,25 @@ const openPr: IGitHubInfo['pullRequest'] = { icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }, }; -const draftPr: IGitHubInfo['pullRequest'] = { - number: 678, - uri: URI.parse('https://github.com/microsoft/vscode/pull/678'), - icon: { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }, -}; +function createMockChange(insertions: number, deletions: number): ISessionFileChange { + return { + modifiedUri: URI.file(`/repo/file-${Math.random().toString(36).slice(2)}.ts`), + insertions, + deletions, + }; +} // ============================================================================ // Fixtures // ============================================================================ +// The session header meta row renders the workspace label followed by the +// contributed pills: the `#` pull request pill (GitHub contribution) and +// the `+/-` diff-stats pill (changes contribution). Both are real toolbar action +// view items resolved through Menus.SessionHeaderMeta. export default defineThemedFixtureGroup({ path: 'sessions/' }, { - SessionHeader_NoPullRequest: defineComponentFixture({ + SessionHeader_Default: defineComponentFixture({ render: (ctx) => renderHeader(ctx, createMockSession({ title: 'Fix login bug', workspace: createMockWorkspace({ label: 'vscode' }), @@ -187,13 +267,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { render: (ctx) => renderHeader(ctx, createMockSession({ title: 'Add session header PR link', workspace: createMockWorkspace({ label: 'vscode', isWorktree: true, pullRequest: openPr }), - })), - }), - - SessionHeader_DraftPullRequest: defineComponentFixture({ - render: (ctx) => renderHeader(ctx, createMockSession({ - title: 'Refactor authentication flow', - workspace: createMockWorkspace({ label: 'vscode', isWorktree: true, pullRequest: draftPr }), + changes: [createMockChange(42, 7), createMockChange(5, 0)], })), }), @@ -202,6 +276,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { title: 'Investigate flaky test', status: SessionStatus.InProgress, workspace: createMockWorkspace({ label: 'vscode', isWorktree: true, pullRequest: openPr }), + changes: [createMockChange(118, 64)], })), }), @@ -217,6 +292,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { render: (ctx) => renderHeader(ctx, createMockSession({ title: 'Investigate and fix the flaky integration test in the notebook editor viewport rendering pipeline', workspace: createMockWorkspace({ label: 'microsoft/vscode', isWorktree: true, pullRequest: openPr }), + changes: [createMockChange(12, 3)], })), }), From e816be7b77a3302365cba6a2a4ebb841ff2f3ecb Mon Sep 17 00:00:00 2001 From: Ben Villalobos Date: Tue, 23 Jun 2026 12:45:31 -0700 Subject: [PATCH 056/696] Fix false partiallySucceeded in CG NOTICE pipeline (#322472) * Fix false partiallySucceeded in CG NOTICE pipeline Gate the cache-restore DownloadPipelineArtifact tasks behind an up-front detect step so they only run when CG NOTICE generation actually failed. Previously they ran on every build and, with no prior cache artifact, failed-with-continueOnError and marked the build partiallySucceeded for no real reason. Also merge the adjacent scan + merge bash steps into one set -e step so a scan failure aborts before merge instead of shipping an incomplete NOTICE that looks green. The detect step carries continueOnError to preserve the block's no-step-can-hard-fail invariant. * Gate apply-fallback step on CG_NOTICE_OK too Skip the apply-fallback step on the happy path instead of running it as a no-op, so a successful build's timeline no longer shows a fallback step re-asking a question the detect step already answered. The internal >1KB file check stays as a fail-open safety net (the detect step runs under continueOnError, so the gate variable can be unset) that guarantees we never overwrite a good NOTICE with a stale cache. * Make merge summary self-explaining (found vs shipped) The old summary listed additive and non-additive counters in one flat list, so the package totals didn't visibly add up. Restructure into: gross packages found across all sources (3594), what got folded during merge (758 duplicates + 16 stub-overrides that hit a package CG already had), count-neutral in-place edits, and the net total included in the shipping NOTICE (2820), with the found - folded = included reconciliation printed inline. Add a defensive warning that fires if a future code path mutates the package map without updating a counter. --- .../azure-pipelines/oss/merge-notices.test.ts | 144 +++++++++++++++++ build/azure-pipelines/oss/merge-notices.ts | 149 ++++++++++++++++-- build/azure-pipelines/oss/scan-licenses.ts | 21 +++ .../product-quality-checks.yml | 33 +++- 4 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 build/azure-pipelines/oss/merge-notices.test.ts diff --git a/build/azure-pipelines/oss/merge-notices.test.ts b/build/azure-pipelines/oss/merge-notices.test.ts new file mode 100644 index 0000000000000..33687f699242b --- /dev/null +++ b/build/azure-pipelines/oss/merge-notices.test.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. + *--------------------------------------------------------------------------------------------*/ + +/*--------------------------------------------------------------------------------------------- + * Focused unit checks for computeUnaccounted() in merge-notices.ts. + * + * Run: npx tsx merge-notices.test.ts + * + * computeUnaccounted cross-checks the scanner's "unresolved" list against the + * FINAL merged NOTICE to surface packages genuinely missing from it -- either + * (a) the scanner failed to create a row AND nothing rescued it downstream, or + * (b) a row exists but its license body is empty. Covers: + * 1. A genuinely-absent unresolved pkg is included. + * 2. An unresolved pkg that IS present in `merged` (any version) is excluded + * -- the linux-keyutils cglicenses.json-override rescue case. + * 3. An entry present with empty licenseText is included (no-license-text). + * 4. An entry present with real license text is excluded. + * 5. Dedupe when the same pkg surfaces from both sources. + *--------------------------------------------------------------------------------------------*/ + +import { computeUnaccounted } from './merge-notices.js'; + +interface NoticeEntry { + name: string; + version: string; + license: string; + url: string; + licenseText: string; +} + +let passed = 0; +let failed = 0; + +function check(name: string, cond: boolean): void { + if (cond) { + passed++; + console.log(` ok ${name}`); + } else { + failed++; + console.error(` FAIL ${name}`); + } +} + +function entry(name: string, version: string, licenseText: string): NoticeEntry { + return { name, version, license: 'MIT', url: '', licenseText }; +} + +function mapOf(...entries: NoticeEntry[]): Map { + const m = new Map(); + for (const e of entries) { + m.set(`${e.name.toLowerCase()}@${e.version || ''}`, e); + } + return m; +} + +// -- 1. genuinely-absent unresolved pkg is included --------------------------- +console.log('computeUnaccounted -- genuinely absent:'); +{ + const merged = mapOf(entry('present-pkg', '1.0.0', 'Real license text')); + const unresolved = [{ name: 'ghost-pkg', version: '2.0.0', reason: 'cargo-no-license-resolved' }]; + const out = computeUnaccounted(merged, unresolved); + check('absent unresolved pkg is listed', out.some(u => u.name === 'ghost-pkg' && u.reason === 'cargo-no-license-resolved')); + check('present pkg with text is NOT listed', !out.some(u => u.name === 'present-pkg')); + check('exactly one unaccounted', out.length === 1); +} + +// -- 2. unresolved pkg present in merged (any version) is excluded ------------- +console.log('computeUnaccounted -- downstream-rescued (linux-keyutils case):'); +{ + // Scanner failed at 0.2.4, but cglicenses.json injected it at a different + // version. Name-only match must exclude it from the unaccounted list. + const merged = mapOf(entry('linux-keyutils', '0.2.5', 'BSD-3-Clause text')); + const unresolved = [{ name: 'linux-keyutils', version: '0.2.4', reason: 'cargo-no-license-resolved' }]; + const out = computeUnaccounted(merged, unresolved); + check('rescued pkg (version drift) is excluded', !out.some(u => u.name === 'linux-keyutils')); + check('nothing unaccounted', out.length === 0); +} +{ + // Exact-version rescue too. + const merged = mapOf(entry('linux-keyutils', '0.2.4', 'BSD-3-Clause text')); + const unresolved = [{ name: 'linux-keyutils', version: '0.2.4', reason: 'cargo-no-license-resolved' }]; + const out = computeUnaccounted(merged, unresolved); + check('rescued pkg (same version) is excluded', out.length === 0); +} + +// -- 3. present entry with empty license body is included ---------------------- +console.log('computeUnaccounted -- empty license body:'); +{ + const merged = mapOf( + entry('blank-pkg', '1.0.0', ' \n\t '), + entry('good-pkg', '1.0.0', 'Real text'), + ); + const out = computeUnaccounted(merged, []); + check('empty-body entry listed as no-license-text', out.some(u => u.name === 'blank-pkg' && u.reason === 'no-license-text')); + check('real-text entry excluded', !out.some(u => u.name === 'good-pkg')); + check('exactly one unaccounted', out.length === 1); +} + +// -- 4. dedupe between the two sources ---------------------------------------- +console.log('computeUnaccounted -- dedupe across sources:'); +{ + // Same pkg is both in unresolved AND present-with-empty-body. (Edge case, but + // the dedupe must keep a single row keyed by name@version.) + const merged = mapOf(entry('dup-pkg', '3.1.4', '')); + const unresolved = [{ name: 'dup-pkg', version: '3.1.4', reason: 'cargo-api-failed' }]; + const out = computeUnaccounted(merged, unresolved); + // Present in merged, so the unresolved branch (name-match) excludes it; the + // empty-body branch adds it once. Either way: exactly one row, not two. + check('single deduped row for dup-pkg', out.filter(u => u.name === 'dup-pkg').length === 1); +} +{ + // Two distinct unresolved entries for the same name@version dedupe to one. + const merged = new Map(); + const unresolved = [ + { name: 'twice', version: '1.0.0', reason: 'cargo-api-failed' }, + { name: 'Twice', version: '1.0.0', reason: 'cargo-no-repo-url' }, + ]; + const out = computeUnaccounted(merged, unresolved); + check('case-insensitive dedupe to one row', out.filter(u => u.name.toLowerCase() === 'twice').length === 1); +} + +// -- 5. sort order ------------------------------------------------------------ +console.log('computeUnaccounted -- sorted by name then version:'); +{ + const merged = new Map(); + const unresolved = [ + { name: 'zeta', version: '1.0.0', reason: 'r' }, + { name: 'alpha', version: '2.0.0', reason: 'r' }, + { name: 'alpha', version: '1.0.0', reason: 'r' }, + ]; + const out = computeUnaccounted(merged, unresolved); + check('first is alpha@1.0.0', out[0].name === 'alpha' && out[0].version === '1.0.0'); + check('second is alpha@2.0.0', out[1].name === 'alpha' && out[1].version === '2.0.0'); + check('last is zeta', out[2].name === 'zeta'); +} + +// -- summary ------------------------------------------------------------------ +console.log(''); +console.log(`merge-notices computeUnaccounted unit checks: ${passed} passed, ${failed} failed`); +if (failed > 0) { + process.exit(1); +} diff --git a/build/azure-pipelines/oss/merge-notices.ts b/build/azure-pipelines/oss/merge-notices.ts index b4f0d9925d6f2..46c78ffde10ca 100644 --- a/build/azure-pipelines/oss/merge-notices.ts +++ b/build/azure-pipelines/oss/merge-notices.ts @@ -116,6 +116,62 @@ function parseNoticeFile(filePath: string): NoticeEntry[] { return entries; } +/** + * Compute the packages NOT accounted for in the final merged NOTICE. A package + * is "unaccounted" if EITHER: + * (a) the scanner tried to resolve it but created no row (it appears in the + * `unresolved` sibling) AND its name is absent from the final merged map + * (name-only match avoids version-drift false positives, and correctly + * excludes packages rescued downstream via a cglicenses.json override), OR + * (b) it IS a row in the final notice but its license body is empty/whitespace. + * + * Pure and exported for unit testing. Informational only — never affects exit code. + */ +export function computeUnaccounted( + merged: Map, + unresolved: Array<{ name: string; version: string; reason: string }> +): Array<{ name: string; version: string; reason: string }> { + const presentNamesLower = new Set([...merged.values()].map(e => e.name.toLowerCase())); + const out: Array<{ name: string; version: string; reason: string }> = []; + + // (a) Genuinely absent: scanner failed AND the name never landed in the notice. + for (const item of unresolved) { + if (!presentNamesLower.has(item.name.toLowerCase())) { + out.push({ name: item.name, version: item.version, reason: item.reason }); + } + } + + // (b) Present but with an empty license body. + for (const entry of merged.values()) { + if (!entry.licenseText || entry.licenseText.trim() === '') { + out.push({ name: entry.name, version: entry.version, reason: 'no-license-text' }); + } + } + + // Dedupe by `@` (a pkg may appear from both sources). + const seen = new Set(); + const deduped: Array<{ name: string; version: string; reason: string }> = []; + for (const u of out) { + const key = `${u.name.toLowerCase()}@${u.version || ''}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(u); + } + + deduped.sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + if (an !== bn) { + return an.localeCompare(bn); + } + return (a.version || '').localeCompare(b.version || ''); + }); + + return deduped; +} + async function mainAsync(): Promise { const args = parseArgs(process.argv.slice(2)); const cgPath = args['cg'] || ''; @@ -409,19 +465,92 @@ required to debug changes to any libraries licensed under the GNU Lesser General const sizeMB = (output.length / 1024 / 1024).toFixed(2); + // Reconcile the numbers so the summary is self-explaining. Only three inputs + // ADD a row to the final NOTICE: CG packages, brand-new extension-scanner + // packages, and injected overrides. Everything else either lands on a package + // CG already covers (folded -> no new row) or edits an existing row in place + // (count-neutral). "Found" is the gross discovery count across all sources; + // "included" is what actually ships after folding the overlaps. + const extScannerFound = extAdded + extStubOverridden + extSkipped; + const totalFound = cgCount + extScannerFound + overridesInjected; + const totalFolded = extSkipped + extStubOverridden; + const totalIncluded = sorted.length; + + // Load the scanner's unresolved index (packages it tried to resolve but + // produced no row for). Cross-checked against the final merged notice below so + // packages rescued downstream (e.g. a cglicenses.json override) are excluded. + // Never throws — a missing/garbled sibling just yields an empty list. + const unresolvedPath = args['unresolved'] || (extPath ? extPath + '.unresolved.json' : ''); + let unresolvedList: Array<{ name: string; version: string; reason: string }> = []; + if (unresolvedPath && fs.existsSync(unresolvedPath)) { + try { + const raw: unknown = JSON.parse(fs.readFileSync(unresolvedPath, 'utf8')); + if (Array.isArray(raw)) { + unresolvedList = (raw as Array<{ name?: unknown; version?: unknown; reason?: unknown }>) + .filter(item => item && typeof item.name === 'string') + .map(item => ({ + name: item.name as string, + version: typeof item.version === 'string' ? item.version : '', + reason: typeof item.reason === 'string' ? item.reason : 'unresolved', + })); + } + } catch (err) { + console.warn(` WARN: could not read unresolved index ${unresolvedPath}: ${(err as Error).message}`); + } + } + console.log('=== Merge Summary ==='); - console.log(` Total packages: ${sorted.length}`); - console.log(` From CG: ${cgCount}`); - console.log(` From extension scanner: ${extAdded}`); - console.log(` Cargo stub-overrides (beat CG): ${extStubOverridden}`); - console.log(` Extension duplicates skipped: ${extSkipped}`); + console.log(''); + console.log(' Packages found (gross, across all sources):'); + console.log(` Component Governance: ${cgCount}`); + console.log(` Extension scanner: ${extScannerFound} (${extAdded} new, ${extSkipped} already in CG, ${extStubOverridden} stub-overrides)`); if (cglicensesPath) { - console.log(` cglicenses overrides applied: ${overridesApplied} (${overrideEntryCount} merged entries updated)`); - console.log(` cglicenses overrides injected: ${overridesInjected}`); - console.log(` cglicenses overrides stale (skipped, warn-only): ${staleOverrides.length}`); - console.log(` cglicenses overrides unmatched (no usable text): ${unmatchedOverrides.length}`); + console.log(` cglicenses.json injected: ${overridesInjected} (present-but-unlicensed, added by override)`); + } + console.log(` Total packages found: ${totalFound}`); + console.log(''); + console.log(' Folded during merge (scanner hit a package CG already had -- no new row):'); + console.log(` Duplicates skipped (CG text kept): ${extSkipped}`); + console.log(` Stub-overrides (CG text replaced): ${extStubOverridden}`); + console.log(` Total folded: ${totalFolded}`); + console.log(''); + if (cglicensesPath) { + console.log(' Edited in place (count-neutral):'); + console.log(` cglicenses overrides applied: ${overridesApplied} (${overrideEntryCount} entries updated)`); + console.log(` cglicenses overrides stale (skipped): ${staleOverrides.length} (warn-only)`); + console.log(` cglicenses overrides unmatched: ${unmatchedOverrides.length} (no usable text)`); + console.log(''); } + console.log(` Total packages included in shipping ThirdPartyNotices: ${totalIncluded}`); + console.log(` (${totalFound} found - ${totalFolded} folded = ${totalIncluded})`); + + const unaccounted = computeUnaccounted(merged, unresolvedList); + console.log(''); + if (unaccounted.length > 0) { + // Surface as warnings so they show up in the ADO build log's warning count + // and any warning-count tooling. Informational only -- never fails the build. + console.warn(` Packages NOT accounted for in the final NOTICE: ${unaccounted.length}`); + console.warn(' (genuinely absent, or present with an empty license body -- need a cglicenses.json override or upstream fix)'); + for (const u of unaccounted) { + console.warn(` ! ${u.name}@${u.version || '(no version)'} -- ${u.reason}`); + } + } else { + // Zero is good news, not a warning. + console.log(` Packages NOT accounted for in the final NOTICE: ${unaccounted.length}`); + } + + // Defensive: if a future code path mutates the merged map without updating a + // counter, this catches the drift instead of silently shipping a wrong total. + if (totalFound - totalFolded !== totalIncluded) { + console.warn(` WARN: merge accounting mismatch -- found(${totalFound}) - folded(${totalFolded}) = ${totalFound - totalFolded}, but the final NOTICE has ${totalIncluded} packages. A source or override path is uncounted.`); + } + + console.log(''); console.log(` Output: ${outputPath} (${sizeMB} MB)`); } -mainAsync().catch(err => { console.error(err); process.exit(1); }); +// Only run mainAsync() when merge-notices is the entry point -- not when a test +// or another module imports the exported helpers (mirrors scan-licenses.ts). +if (/merge-notices(\.[jt]s)?$/.test(process.argv[1] || '')) { + mainAsync().catch(err => { console.error(err); process.exit(1); }); +} diff --git a/build/azure-pipelines/oss/scan-licenses.ts b/build/azure-pipelines/oss/scan-licenses.ts index 0f8806b59e2b6..9997a8ce31e55 100644 --- a/build/azure-pipelines/oss/scan-licenses.ts +++ b/build/azure-pipelines/oss/scan-licenses.ts @@ -1101,6 +1101,12 @@ async function main(): Promise { // *.stuboverride.json file that merge-notices.ts consumes to let the scanner // entry beat CG on collision (mirrors the presence.json sibling pattern). const stubOverrideKeys = new Set(); + // Unresolved index: packages the scanner TRIED to resolve but for which it + // created NO row at all (fetch failed / no license / API failed). Written as + // a sibling *.unresolved.json that merge-notices.ts cross-checks against the + // final merged NOTICE so packages rescued downstream (e.g. via a + // cglicenses.json override) are excluded from the "not accounted for" list. + const unresolved: Array<{ name: string; version: string; reason: string }> = []; let scanned = 0; let noLicense = 0; @@ -1358,6 +1364,7 @@ async function main(): Promise { continue; } cgManifestFetchFailed++; + unresolved.push({ name, version: reg.version || commitHash.substring(0, 7), reason: 'cgmanifest-fetch-failed' }); console.warn(` FETCH FAILED: ${name} (${repoUrl}@${commitHash.substring(0, 7)}) -- no LICENSE resolved`); continue; } @@ -1484,6 +1491,7 @@ async function main(): Promise { const info = await fetchCratesIoJson(p.name); if (!info) { cargoApiFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-api-failed' }); console.warn(` CRATES.IO FAILED: ${p.name}@${p.version} -- no crate info`); return; } @@ -1495,6 +1503,7 @@ async function main(): Promise { // Spec sec. 6.6: a failed crates.io call must log and continue, never crash. if (!Array.isArray(info.versions) || !info.crate || typeof info.crate.id !== 'string') { cargoApiFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-api-failed' }); console.warn(` CRATES.IO FAILED: ${p.name}@${p.version} -- unexpected response shape (no versions/crate)`); return; } @@ -1506,6 +1515,7 @@ async function main(): Promise { const repoUrl = getCrateRepository(info); if (!repoUrl) { cargoFetchFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-no-repo-url' }); console.warn(` FETCH FAILED: ${p.name}@${p.version} -- no repository URL`); return; } @@ -1518,6 +1528,7 @@ async function main(): Promise { const result = await fetchCargoLicense(repoUrl, p.name, p.version, license); if (!result) { cargoFetchFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-no-license-resolved' }); console.warn(` FETCH FAILED: ${p.name}@${p.version} (${repoUrl}) -- no LICENSE resolved at any ref (needs cglicenses.json override)`); return; } @@ -1553,6 +1564,7 @@ async function main(): Promise { // Defense-in-depth: any unexpected throw in this task must log and // continue, never reject (which would crash the build per sec. 6.6). cargoApiFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-api-failed' }); console.warn(` CRATES.IO FAILED: ${p.name}@${p.version} -- unexpected error: ${(err as Error).message}`); } }); @@ -2131,6 +2143,14 @@ async function main(): Promise { const stubOverrideList = [...stubOverrideKeys].sort(); fs.writeFileSync(stubOverridePath, JSON.stringify(stubOverrideList, null, '\t'), 'utf8'); + // Write the unresolved index as a sibling file. These are packages the scanner + // tried to resolve but produced NO row for. merge-notices.ts cross-checks this + // against the final merged NOTICE so packages rescued downstream (e.g. a + // cglicenses.json override) are excluded. Mirrors the presence.json sibling. + const unresolvedPath = args['unresolved'] || (outputPath + '.unresolved.json'); + const unresolvedSorted = unresolved.slice().sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + fs.writeFileSync(unresolvedPath, JSON.stringify(unresolvedSorted, null, '\t'), 'utf8'); + // Summary console.log(''); console.log('=== License Scan Summary ==='); @@ -2188,6 +2208,7 @@ async function main(): Promise { console.log(` Output: ${outputPath}`); console.log(` Presence index (present but unlicensed): ${presence.length}`); console.log(` Presence output: ${presencePath}`); + console.log(` Unresolved (tried, no row created): ${unresolved.length}`); console.log(` Stub-override index: ${stubOverrideList.length}`); console.log(` Stub-override output: ${stubOverridePath}`); diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml index b7919b52e47ff..e23a1e79aecc1 100644 --- a/build/azure-pipelines/product-quality-checks.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -170,6 +170,26 @@ jobs: # run fresh against current HEAD, so extension licenses and # cglicenses.json overrides are always current-PR-accurate. # ========================================================================= + + # Detect up front whether CG produced a usable NOTICE. The cache-download + # tasks below run ONLY when it did not -- otherwise they execute on the + # happy path and, when no prior cache artifact exists (e.g. the first build + # on a branch), fail-with-continueOnError and mark the whole build + # partiallySucceeded for no real reason. Uses the same >1KB heuristic as + # the "apply NOTICE fallback" step below so the two always agree. + - script: | + set -u + GEN="$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" + if [ -f "$GEN" ] && [ "$(wc -c < "$GEN")" -gt 1024 ]; then + echo "##[section]CG NOTICE generation succeeded; skipping cache download." + echo "##vso[task.setvariable variable=CG_NOTICE_OK]true" + else + echo "##[warning]CG NOTICE generation failed or produced empty output; will attempt cache fallback." + echo "##vso[task.setvariable variable=CG_NOTICE_OK]false" + fi + displayName: "Cache: detect whether CG NOTICE needs fallback" + continueOnError: true + - task: DownloadPipelineArtifact@2 displayName: "Cache: download last good NOTICE (same branch)" inputs: @@ -183,6 +203,7 @@ jobs: itemPattern: "**/{ThirdPartyNotices.generated.txt,notice-meta.txt}" targetPath: $(Pipeline.Workspace)/cached-notice-branch continueOnError: true + condition: and(succeeded(), ne(variables.CG_NOTICE_OK, 'true')) - task: DownloadPipelineArtifact@2 displayName: "Cache: download last good NOTICE (main fallback)" @@ -197,7 +218,7 @@ jobs: itemPattern: "**/{ThirdPartyNotices.generated.txt,notice-meta.txt}" targetPath: $(Pipeline.Workspace)/cached-notice-main continueOnError: true - condition: ne(variables['Build.SourceBranch'], 'refs/heads/main') + condition: and(succeeded(), ne(variables.CG_NOTICE_OK, 'true'), ne(variables['Build.SourceBranch'], 'refs/heads/main')) - script: | set -u @@ -220,7 +241,8 @@ jobs: fi } - # `notice@0` succeeded if the file exists and is non-trivial (>1KB). + # Safety net: never overwrite a valid NOTICE. If CG produced a good + # file, skip the fallback even if we were reached by mistake. if [ -f "$GEN" ] && [ "$(wc -c < "$GEN")" -gt 1024 ]; then echo "##[section]CG NOTICE generation succeeded; no fallback needed." echo "##vso[task.setvariable variable=NOTICE_FROM_CACHE]false" @@ -250,6 +272,7 @@ jobs: echo "##vso[task.setvariable variable=NOTICE_FROM_CACHE]none" displayName: "Cache: apply NOTICE fallback if CG failed" continueOnError: true + condition: and(succeeded(), ne(variables.CG_NOTICE_OK, 'true')) - script: | echo "=== CG NOTICE artifact ===" @@ -291,10 +314,6 @@ jobs: --repo "$(Build.SourcesDirectory)" \ --cg "$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" \ --output "$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.extensions.txt" - displayName: "Scan extension-bundled licenses" - continueOnError: true - - - script: | node .oss-build-out/merge-notices.js \ --cg "$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" \ --extensions "$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.extensions.txt" \ @@ -305,7 +324,7 @@ jobs: # Upload all artifacts echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.extensions.txt" echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.new.txt" - displayName: "Merge CG + extension notices into .new.txt" + displayName: "Build merged NOTICE (scan extensions + merge into .new.txt)" continueOnError: true - task: AzureCLI@2 From 303fa03c55e31c4b049d83e32f46993c07e8898f Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:58:44 -0700 Subject: [PATCH 057/696] Fix flaky ClaudeCustomizationWatcher debounce tests (#322601) --- ...laudeSessionCustomizationDiscovery.test.ts | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts index 050924241de68..56e7499fa30f6 100644 --- a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts @@ -208,33 +208,45 @@ suite('claudeSessionCustomizationDiscovery', () => { }); suite('ClaudeCustomizationWatcher', () => { + // Debounce wide enough that a burst of edits reliably lands in a single + // window even on a loaded CI machine; `settle` then waits a clear + // multiple of it so the one debounced fire is always counted before we + // assert. The burst itself is issued concurrently so the change events + // cluster inside one window rather than racing the debounce. + const debounceMs = 20; + const settle = () => timeout(debounceMs * 6); + test('fires once (debounced) for changes under watched roots and ignores unrelated edits', async () => { - const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), 5)); + const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), debounceMs)); let fires = 0; disposables.add(watcher.onDidChange(() => { fires++; })); // An unrelated edit in the workspace root must NOT trigger a refresh. await seed('/workspace/unrelated.txt', 'x'); - await timeout(40); + await settle(); assert.strictEqual(fires, 0); // A burst of edits across the watched roots collapses to a single fire: // a user-scope agent, a project-scope skill, and the sibling `.mcp.json`. - await seed('/home/.claude/agents/a.md', 'a'); - await seed('/workspace/.claude/skills/s/SKILL.md', 's'); - await seed('/workspace/.mcp.json', '{}'); - await timeout(40); + await Promise.all([ + seed('/home/.claude/agents/a.md', 'a'), + seed('/workspace/.claude/skills/s/SKILL.md', 's'), + seed('/workspace/.mcp.json', '{}'), + ]); + await settle(); assert.strictEqual(fires, 1); }); test('fires for a root-level CLAUDE.md / CLAUDE.local.md edit', async () => { - const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), 5)); + const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), debounceMs)); let fires = 0; disposables.add(watcher.onDidChange(() => { fires++; })); - await seed('/workspace/CLAUDE.md', '# memory'); - await seed('/workspace/CLAUDE.local.md', '# personal'); - await timeout(40); + await Promise.all([ + seed('/workspace/CLAUDE.md', '# memory'), + seed('/workspace/CLAUDE.local.md', '# personal'), + ]); + await settle(); assert.strictEqual(fires, 1); }); }); From 0cd0e6fc1b10e904f8533b69d8b2a7fb0fb06970 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 23 Jun 2026 16:01:07 -0400 Subject: [PATCH 058/696] Voice chat input fixes (#322589) --- .../browser/agentsVoice.contribution.ts | 55 ++++++++--- .../agentsVoice/browser/agentsVoiceWidget.ts | 2 + .../browser/agentsVoiceWidgetBinding.ts | 7 +- .../browser/agentsVoiceWindowService.ts | 2 + .../browser/components/headerComponent.ts | 3 +- .../voiceClient/voiceSessionController.ts | 13 ++- .../widgetHosts/viewPane/chatViewPane.ts | 99 +++++++++++++++---- 7 files changed, 141 insertions(+), 40 deletions(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index ecae669845382..fc1a4db1589bf 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -46,6 +46,7 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { ChatAgentLocation } from '../../chat/common/constants.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; // --- Context Keys --- @@ -55,6 +56,8 @@ const AGENTS_VOICE_CONNECTED = new RawContextKey('agentsVoiceConnected' const AGENTS_VOICE_CONNECTING = new RawContextKey('agentsVoiceConnecting', false); const AGENTS_VOICE_LISTENING = new RawContextKey('agentsVoiceListening', false); const AGENTS_VOICE_ACTIVE = new RawContextKey('agentsVoiceActive', false); +/** Set on the specific widget where voice was initiated — used to scope connecting/connected UI to that widget only. */ +const AGENTS_VOICE_INITIATED_HERE = new RawContextKey('agentsVoiceInitiatedHere', false); // --- Context Key Binding --- @@ -88,6 +91,7 @@ class AgentsVoiceConnectedKeyContribution extends Disposable implements IWorkben constructor( @IVoiceSessionController voiceSessionController: IVoiceSessionController, @IContextKeyService contextKeyService: IContextKeyService, + @IChatWidgetService chatWidgetService: IChatWidgetService, ) { super(); @@ -95,12 +99,22 @@ class AgentsVoiceConnectedKeyContribution extends Disposable implements IWorkben const connectingKey = AGENTS_VOICE_CONNECTING.bindTo(contextKeyService); const listeningKey = AGENTS_VOICE_LISTENING.bindTo(contextKeyService); const activeKey = AGENTS_VOICE_ACTIVE.bindTo(contextKeyService); + let wasConnected = false; this._register(autorun(reader => { - connectedKey.set(voiceSessionController.isConnected.read(reader)); + const connected = voiceSessionController.isConnected.read(reader); + connectedKey.set(connected); connectingKey.set(voiceSessionController.isConnecting.read(reader)); const state = voiceSessionController.voiceState.read(reader); listeningKey.set(state === 'listening'); activeKey.set(state === 'listening' || state === 'speaking'); + + // Clear per-widget "initiated here" key when voice disconnects + if (wasConnected && !connected) { + for (const widget of chatWidgetService.getAllWidgets()) { + AGENTS_VOICE_INITIATED_HERE.bindTo(widget.scopedContextKeyService).set(false); + } + } + wasConnected = connected; })); } } @@ -159,6 +173,7 @@ registerAction2(class extends Action2 { ContextKeyExpr.equals('config.agents.voice.enabled', true), AGENTS_VOICE_CONNECTED.isEqualTo(true), ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), ), group: 'navigation', order: 6 @@ -202,6 +217,7 @@ registerAction2(class extends Action2 { ContextKeyExpr.equals('config.agents.voice.enabled', true), ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), AGENTS_VOICE_CONNECTING.isEqualTo(true), + AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), ), group: 'navigation', order: 4 @@ -225,6 +241,7 @@ registerAction2(class extends Action2 { when: ContextKeyExpr.and( ContextKeyExpr.equals('config.agents.voice.enabled', true), ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.currentlyEditing.negate(), AGENTS_VOICE_ACTIVE.negate(), AGENTS_VOICE_CONNECTING.negate(), ), @@ -244,6 +261,12 @@ registerAction2(class extends Action2 { async run(accessor: ServicesAccessor): Promise { const voiceController = accessor.get(IVoiceSessionController); if (!voiceController.isConnected.get()) { + // Mark this widget as the one where voice was initiated + const chatWidgetService = accessor.get(IChatWidgetService); + const widget = chatWidgetService.lastFocusedWidget; + if (widget) { + AGENTS_VOICE_INITIATED_HERE.bindTo(widget.scopedContextKeyService).set(true); + } await voiceController.connect(mainWindow); } else { voiceController.pttDown(); @@ -267,7 +290,9 @@ registerAction2(class extends Action2 { when: ContextKeyExpr.and( ContextKeyExpr.equals('config.agents.voice.enabled', true), ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.currentlyEditing.negate(), AGENTS_VOICE_ACTIVE.isEqualTo(true), + AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), ), group: 'navigation', order: 4 @@ -285,9 +310,17 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor): Promise { const voiceController = accessor.get(IVoiceSessionController); - // Stop recording and send - voiceController.pttDown(); - voiceController.pttUp(); + // In auto-send mode, toggling voice mode off disconnects entirely. + // The auto-listen loop means there's no natural "idle" state to return to. + const configService = accessor.get(IConfigurationService); + const autoSendDelay = configService.getValue('agents.voice.autoSendDelay') ?? 500; + if (autoSendDelay >= 0) { + voiceController.disconnect(); + } else { + // Manual mode: just stop recording + voiceController.pttDown(); + voiceController.pttUp(); + } } }); @@ -304,16 +337,6 @@ registerAction2(class extends Action2 { ContextKeyExpr.equals('config.agents.voice.enabled', true), AGENTS_VOICE_CONNECTED.isEqualTo(true), ), - menu: { - id: MenuId.ChatExecute, - when: ContextKeyExpr.and( - ContextKeyExpr.equals('config.agents.voice.enabled', true), - AGENTS_VOICE_CONNECTED.isEqualTo(true), - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), - ), - group: 'navigation', - order: 5 - }, }); } async run(accessor: ServicesAccessor): Promise { @@ -485,7 +508,7 @@ configurationRegistry.registerConfiguration({ 'agents.voice.showTranscript': { type: 'boolean', description: nls.localize('agents.voice.showTranscript', "Show the voice transcript overlay in the chat input area while voice mode is active."), - default: true, + default: false, scope: ConfigurationScope.APPLICATION, included: false, tags: ['advanced'], @@ -493,7 +516,7 @@ configurationRegistry.registerConfiguration({ 'agents.voice.autoSendDelay': { type: 'number', description: nls.localize('agents.voice.autoSendDelay', "In toggle voice mode (short tap), automatically finish recording after this many milliseconds of silence. Set to -1 to disable."), - default: 1000, + default: 500, minimum: -1, scope: ConfigurationScope.APPLICATION, included: false, diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index 6b0d8a3875663..dde2f1cdae65e 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -22,6 +22,7 @@ import type { VoiceState, IPendingToolConfirmation, ITranscriptTurn } from '../. export interface VoiceWidgetCallbacks { readonly copilotIconSrc: string; + readonly hideDisconnect: boolean; connect(): void; disconnect(): void; pttDown(): void; @@ -756,6 +757,7 @@ export class AgentsVoiceWidget extends Disposable { draggable: opts.draggable, showClose: opts.showClose, showPopout: !!this.callbacks.openPopout && this._popoutAvailable.read(reader), + hideDisconnect: this.callbacks.hideDisconnect, centerConnectButton: opts.centerConnectButton, onMicDown: (e: MouseEvent) => { e.preventDefault(); this.callbacks.pttDown(); }, onMicUp: () => { this.callbacks.pttUp(); }, diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts index f8b180ea20d22..264ed6bc75057 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts @@ -13,6 +13,7 @@ import { IVoicePlaybackService } from '../../chat/common/voicePlaybackService.js import { IVoiceSessionController } from '../../chat/browser/voiceClient/voiceSessionController.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IChatService } from '../../chat/common/chatService/chatService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { AgentsVoiceWidget } from './agentsVoiceWidget.js'; import { getRepositoryName } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; import type { SessionGroupData, SessionRowData } from './components/sessionListComponent.js'; @@ -24,6 +25,7 @@ export interface IWidgetBindingServices { readonly voicePlaybackService: IVoicePlaybackService; readonly environmentService: IWorkbenchEnvironmentService; readonly chatService?: IChatService; + readonly configurationService?: IConfigurationService; } /** @@ -39,6 +41,7 @@ export function bindWidgetToController(widget: AgentsVoiceWidget, services: IWid agentTitleBarStatusService, voicePlaybackService, environmentService, + configurationService, } = services; // --- Reactive controller state → widget setters --- @@ -58,7 +61,9 @@ export function bindWidgetToController(widget: AgentsVoiceWidget, services: IWid widget.setReconnecting(reconnecting); widget.setVoiceState(state); widget.setPendingToolConfirmations(toolConfirmations); - widget.setTranscriptTurns(turns); + // Respect showTranscript setting — hide transcript when disabled + const showTranscript = configurationService?.getValue('agents.voice.showTranscript') !== false; + widget.setTranscriptTurns(showTranscript ? turns : []); widget.setStatusText(statusText); widget.setSelectedTargetSession(targetSession); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index 66d7cba2c13b7..d48ba7fb023c0 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -164,6 +164,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // expand them via the chevron. const widget = new AgentsVoiceWidget(auxiliaryWindow.container, { copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), + hideDisconnect: (this.configurationService.getValue('agents.voice.autoSendDelay') ?? 500) >= 0, connect: () => { // Connecting from any surface marks onboarding as completed so // the main panel drops it too. @@ -250,6 +251,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice voicePlaybackService: this.voicePlaybackService, environmentService: this.environmentService, chatService: this.chatService, + configurationService: this.configurationService, })); // Poll for session updates diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts index 2d795e56fb89d..926551d656971 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts @@ -19,6 +19,7 @@ export interface HeaderProps { readonly draggable: boolean; readonly showClose: boolean; readonly showPopout: boolean; + readonly hideDisconnect: boolean; readonly centerConnectButton: boolean; readonly onMicDown: (e: MouseEvent) => void; readonly onMicUp: () => void; @@ -179,7 +180,7 @@ export function createHeader(): HeaderComponent { gearBtn.onclick = props.onPttKeyClick; // Connection indicator - connIndicator.style.display = showConnected ? 'inline-flex' : 'none'; + connIndicator.style.display = showConnected && !props.hideDisconnect ? 'inline-flex' : 'none'; connIndicator.onclick = props.onDisconnectClick; // Spacer / center connect button diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 946543f4f2c61..85551e7aed72c 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -174,6 +174,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _awaitingReplyWatchdog: ReturnType | undefined; /** Enter listening immediately after greeting finishes (no debounce). */ private _autoListenAfterGreeting = false; + /** Tracks whether the initial listen cue has been played after connecting. */ + private _hasPlayedInitialListenCue = false; // --- Audio FIFO queue --- private readonly _audioQueue: { sessionId: string | undefined; chunks: { audio: string; isFirstChunk: boolean; isFinal: boolean; transcript: string | undefined }[] }[] = []; @@ -1055,6 +1057,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._clearAutoListenTimer(); this._clearAwaitingReply(); this._autoListenAfterGreeting = false; + this._hasPlayedInitialListenCue = false; this._replyPlayedSinceSend = false; this._audioQueue.length = 0; this._currentPlaybackSessionId = null; @@ -1182,9 +1185,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this.ttsPlaybackService.stopPlayback(); this._voiceState.set('listening', undefined); this._statusText.set('Listening...', undefined); - // Audible cue when hands-free mode re-arms listening. + // Audible cue: for non-screen-reader users, only play on the first + // listen after connecting. For screen reader users, play every time. if (this._isAutoSendEnabled()) { - this.accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStarted); + if (!this._hasPlayedInitialListenCue) { + this._hasPlayedInitialListenCue = true; + this.accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStarted); + } else if (this.accessibilityService.isScreenReaderOptimized()) { + this.accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStarted); + } } this._pttMaxDurationTimer = setTimeout(() => { 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 df51b08cba492..73dc2141ea9f5 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -59,7 +59,7 @@ import { IChatViewsWelcomeDescriptor } from '../../viewsWelcome/chatViewsWelcome import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../../services/layout/browser/layoutService.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; -import { ChatViewId } from '../../chat.js'; +import { ChatViewId, IChatWidgetService } from '../../chat.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { disposableTimeout } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; @@ -141,6 +141,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @ITtsPlaybackService private readonly ttsPlaybackService: ITtsPlaybackService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @IAgentsVoiceWindowService private readonly agentsVoiceWindowService: IAgentsVoiceWindowService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IAgentTitleBarStatusService _agentTitleBarStatusService: IAgentTitleBarStatusService, @IVoicePlaybackService _voicePlaybackService: IVoicePlaybackService, @IWorkbenchEnvironmentService _workbenchEnvironmentService: IWorkbenchEnvironmentService, @@ -386,9 +387,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (this.configurationService.getValue('agents.voice.enabled')) { // Voice command bridge — lets the VoiceSessionController reach into the chat widget - this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.acceptInput', (_accessor, text: string) => { - if (text && this._widget?.viewModel) { - this._widget.acceptInput(text, { preserveFocus: true }); + this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.acceptInput', (accessor, text: string) => { + const chatWidgetService = accessor.get(IChatWidgetService); + const widget = chatWidgetService.lastFocusedWidget ?? this._widget; + if (text && widget?.viewModel) { + if (widget.viewModel.editing) { + // When editing an old message, populate the active input + // editor so the user can review before submitting. + widget.input.setValue(text, false); + } else { + widget.acceptInput(text, { preserveFocus: true }); + } } })); this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.switchToSession', (_accessor, resourceStr: string): boolean => { @@ -425,6 +434,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { let animFrameId: number | undefined; let glowDataArray: Uint8Array | undefined; const win = getWindow(inputContainerEl); + let lastGlowTarget: HTMLElement | undefined; + // Lock the glow target to whichever widget was focused when voice connected. + // This prevents confirmations/programmatic focus shifts from moving the glow. + let lockedGlowWidget: HTMLElement | undefined; + const getActiveInputContainer = (): HTMLElement => { + if (lockedGlowWidget) { + return lockedGlowWidget; + } + const focused = this.chatWidgetService.lastFocusedWidget; + return focused?.input?.inputContainerElement ?? inputContainerEl; + }; const startGlowAnimation = () => { if (animFrameId !== undefined) { return; } const animate = () => { @@ -432,11 +452,20 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const connected = this.voiceSessionController.isConnected.get(); const voiceState = this.voiceSessionController.voiceState.get(); const glowActive = connected && (voiceState === 'listening' || voiceState === 'speaking'); + const target = getActiveInputContainer(); + + // If the target changed, clear styling on the old one + if (lastGlowTarget && lastGlowTarget !== target) { + lastGlowTarget.style.borderColor = ''; + lastGlowTarget.style.boxShadow = ''; + lastGlowTarget.classList.remove('voice-active', 'voice-listening'); + } + lastGlowTarget = target; if (!glowActive) { - inputContainerEl.style.borderColor = ''; - inputContainerEl.style.boxShadow = ''; - inputContainerEl.classList.remove('voice-active', 'voice-listening'); + target.style.borderColor = ''; + target.style.boxShadow = ''; + target.classList.remove('voice-active', 'voice-listening'); return; } @@ -459,15 +488,32 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { intensity = Math.min(1, (sum / glowDataArray.length) / 80); } - // Blue when listening, purple when speaking + // Blue when listening (more active/flashy when transcript hidden), purple when speaking (subtle) const rgb = voiceState === 'speaking' ? '163,113,247' : '88,166,255'; - const borderAlpha = 0.4 + intensity * 0.5; - const shadowSpread = 4 + intensity * 12; - const shadowAlpha = 0.15 + intensity * 0.35; - inputContainerEl.style.borderColor = `rgba(${rgb},${borderAlpha})`; - inputContainerEl.style.boxShadow = `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${rgb},${shadowAlpha * 0.3})`; - inputContainerEl.classList.add('voice-active'); - inputContainerEl.classList.toggle('voice-listening', voiceState === 'listening'); + const transcriptHidden = this.configurationService.getValue('agents.voice.showTranscript') === false; + let borderAlpha: number; + let shadowSpread: number; + let shadowAlpha: number; + if (voiceState === 'listening' && transcriptHidden) { + // Flashy, audio-reactive glow while user is speaking (no transcript visible) + borderAlpha = 0.6 + intensity * 0.4; + shadowSpread = 6 + intensity * 20; + shadowAlpha = 0.25 + intensity * 0.55; + } else { + // Standard glow (transcript visible or TTS playback) + borderAlpha = 0.4 + intensity * 0.5; + shadowSpread = 4 + intensity * 12; + shadowAlpha = 0.15 + intensity * 0.35; + } + target.style.borderColor = `rgba(${rgb},${borderAlpha})`; + if (voiceState === 'listening' && transcriptHidden) { + // Double-layer glow for extra presence when listening without transcript + target.style.boxShadow = `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha}), 0 0 ${shadowSpread * 2}px rgba(${rgb},${shadowAlpha * 0.3}), inset 0 0 ${shadowSpread * 0.5}px rgba(${rgb},${shadowAlpha * 0.4})`; + } else { + target.style.boxShadow = `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${rgb},${shadowAlpha * 0.3})`; + } + target.classList.add('voice-active'); + target.classList.toggle('voice-listening', voiceState === 'listening'); }; animFrameId = win.requestAnimationFrame(animate); }; @@ -476,11 +522,23 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { win.cancelAnimationFrame(animFrameId); animFrameId = undefined; } - inputContainerEl.style.borderColor = ''; - inputContainerEl.style.boxShadow = ''; - inputContainerEl.classList.remove('voice-active', 'voice-listening'); + const target = lastGlowTarget ?? inputContainerEl; + target.style.borderColor = ''; + target.style.boxShadow = ''; + target.classList.remove('voice-active', 'voice-listening'); + lastGlowTarget = undefined; }; + // Lock glow target on connect, unlock on disconnect + this._register(autorun(reader => { + const connected = this.voiceSessionController.isConnected.read(reader); + if (connected && !lockedGlowWidget) { + lockedGlowWidget = this.chatWidgetService.lastFocusedWidget?.input?.inputContainerElement ?? inputContainerEl; + } else if (!connected) { + lockedGlowWidget = undefined; + } + })); + this._register(autorun(reader => { const connected = this.voiceSessionController.isConnected.read(reader); const voiceState = this.voiceSessionController.voiceState.read(reader); @@ -517,7 +575,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Show hint when connected but no transcript yet if (visible.length === 0 || !showTranscript) { - if (voiceState === 'idle' && visible.length === 0) { + const autoSendDelay = this.configurationService.getValue('agents.voice.autoSendDelay') ?? 500; + if (voiceState === 'idle' && visible.length === 0 && showTranscript && autoSendDelay < 0) { transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); @@ -538,7 +597,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.add('has-transcript'); - // Show only the latest turn: user question first, then assistant reply replaces it + // Show only the latest visible turn const lastTurn = visible[visible.length - 1]; const contentElements: HTMLElement[] = []; if (lastTurn.speaker === 'user') { From 6d2d406f0239da88677d43738ea4f3bc407bb7b1 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:25:31 -0700 Subject: [PATCH 059/696] Add mute button for local agent host continuation tip (#322599) * Add mute button for local agent host continuation tip * changes * DOnt change wording * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test changes * fix mute cleanup --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../localAgentDisabledInputTipContribution.ts | 42 ++++++++++ ...lAgentDisabledInputTipContribution.test.ts | 80 +++++++++++++++++-- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentDisabledInputTipContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentDisabledInputTipContribution.ts index 3e2bf03e608af..b6c8d00b41c96 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentDisabledInputTipContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentDisabledInputTipContribution.ts @@ -8,6 +8,7 @@ import { localize } from '../../../../../nls.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -17,17 +18,27 @@ import { IChatWidget, IChatWidgetService, isIChatResourceViewContext } from '../ import { ChatInputNotificationSeverity, IChatInputNotificationService } from '../widget/input/chatInputNotificationService.js'; const LOCAL_AGENT_DISABLED_NOTIFICATION_ID = 'chat.localAgentDisabled.continueInAgentHostCopilot'; +export const LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY = 'chat.localAgentDisabled.continueInAgentHostCopilot.muted'; export const LOCAL_AGENT_DISABLED_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID = '_chat.localAgentDisabled.continueInAgentHostCopilot'; +export const LOCAL_AGENT_DISABLED_MUTE_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID = '_chat.localAgentDisabled.muteContinueInAgentHostCopilot'; CommandsRegistry.registerCommand(LOCAL_AGENT_DISABLED_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID, async (accessor: ServicesAccessor) => { const chatWidgetService = accessor.get(IChatWidgetService); const chatSessionsService = accessor.get(IChatSessionsService); + const notificationService = accessor.get(IChatInputNotificationService); const widget = chatWidgetService.lastFocusedWidget; if (!widget || !chatSessionsService.getChatSessionContribution(SessionType.AgentHostCopilot)) { return; } widget.input.continueInSession(SessionType.AgentHostCopilot); + notificationService.dismissNotification(LOCAL_AGENT_DISABLED_NOTIFICATION_ID); +}); + +CommandsRegistry.registerCommand(LOCAL_AGENT_DISABLED_MUTE_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID, (accessor: ServicesAccessor) => { + const storageService = accessor.get(IStorageService); + + storageService.store(LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY, true, StorageScope.PROFILE, StorageTarget.USER); }); export class LocalAgentDisabledInputTipContribution extends Disposable implements IWorkbenchContribution { @@ -35,12 +46,14 @@ export class LocalAgentDisabledInputTipContribution extends Disposable implement static readonly ID = 'workbench.contrib.localAgentDisabledInputTip'; private _lastPostedFor: string | undefined; + private _dismissedForWindow = false; constructor( @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IChatInputNotificationService private readonly notificationService: IChatInputNotificationService, @IConfigurationService private readonly configurationService: IConfigurationService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -52,11 +65,24 @@ export class LocalAgentDisabledInputTipContribution extends Disposable implement this.update(true); } })); + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY, this._store)(() => { + this.update(true); + })); + this._register(this.notificationService.onDidDismiss(id => { + if (id === LOCAL_AGENT_DISABLED_NOTIFICATION_ID) { + this.dismissForWindow(); + } + })); this.update(); } private update(resetDismissal = false): void { + if (this.isMuted() || this._dismissedForWindow) { + this.clear(); + return; + } + const widget = this.chatWidgetService.lastFocusedWidget; const sessionResource = widget?.viewModel?.sessionResource; const key = sessionResource?.toString(); @@ -82,10 +108,26 @@ export class LocalAgentDisabledInputTipContribution extends Disposable implement }], dismissible: true, autoDismissOnMessage: false, + mute: { + commandId: LOCAL_AGENT_DISABLED_MUTE_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID, + tooltip: localize('chat.localAgentDisabled.continueInAgentHostCopilot.mute', "Don't Show Again"), + }, sessionTypes: [localChatSessionType], }); } + private isMuted(): boolean { + return this.storageService.getBoolean(LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY, StorageScope.PROFILE, false); + } + + private dismissForWindow(): void { + if (this._dismissedForWindow) { + return; + } + this._dismissedForWindow = true; + this.clear(); + } + private isEligible(widget: IChatWidget): boolean { const model = widget.viewModel?.model; const sessionResource = widget.viewModel?.sessionResource; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentDisabledInputTipContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentDisabledInputTipContribution.test.ts index a4c235f80e851..62ee842cdab76 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentDisabledInputTipContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentDisabledInputTipContribution.test.ts @@ -13,9 +13,10 @@ import { ConfigurationTarget } from '../../../../../../platform/configuration/co import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { IChatWidget, IChatWidgetService, IChatWidgetViewContext } from '../../../browser/chat.js'; -import { LocalAgentDisabledInputTipContribution, LOCAL_AGENT_DISABLED_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID } from '../../../browser/agentSessions/localAgentDisabledInputTipContribution.js'; +import { LocalAgentDisabledInputTipContribution, LOCAL_AGENT_DISABLED_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID, LOCAL_AGENT_DISABLED_MUTE_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID, LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY } from '../../../browser/agentSessions/localAgentDisabledInputTipContribution.js'; import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js'; import { IChatModel } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; @@ -60,10 +61,13 @@ class TestChatWidgetService implements IChatWidgetService { class TestChatInputNotificationService implements IChatInputNotificationService { declare readonly _serviceBrand: undefined; + private readonly _onDidDismiss = new Emitter(); + readonly onDidDismiss = this._onDidDismiss.event; + readonly onDidChange = Event.None; - readonly onDidDismiss = Event.None; readonly setCalls: IChatInputNotification[] = []; readonly deleteCalls: string[] = []; + readonly dismissedCalls: string[] = []; setNotification(notification: IChatInputNotification): void { this.setCalls.push(notification); @@ -73,7 +77,11 @@ class TestChatInputNotificationService implements IChatInputNotificationService this.deleteCalls.push(id); } - dismissNotification(): void { } + dismissNotification(id: string): void { + this.dismissedCalls.push(id); + this._onDidDismiss.fire(id); + } + getActiveNotification(): IChatInputNotification | undefined { return this.setCalls.at(-1); } handleMessageSent(): void { } } @@ -85,6 +93,7 @@ suite('LocalAgentDisabledInputTipContribution', () => { let notificationService: TestChatInputNotificationService; let configurationService: TestConfigurationService; let chatSessionsService: MockChatSessionsService; + let storageService: InMemoryStorageService; let testDisposables: DisposableStore; setup(() => { @@ -97,6 +106,7 @@ suite('LocalAgentDisabledInputTipContribution', () => { }); chatSessionsService = new MockChatSessionsService(); chatSessionsService.setContributions([createContribution(SessionType.AgentHostCopilot)]); + storageService = testDisposables.add(new InMemoryStorageService()); }); teardown(() => { @@ -113,7 +123,7 @@ suite('LocalAgentDisabledInputTipContribution', () => { } function createTipContribution(): LocalAgentDisabledInputTipContribution { - const contribution = new LocalAgentDisabledInputTipContribution(widgetService, notificationService, configurationService, chatSessionsService); + const contribution = new LocalAgentDisabledInputTipContribution(widgetService, notificationService, configurationService, chatSessionsService, storageService); testDisposables.add(contribution); return contribution; } @@ -158,13 +168,18 @@ suite('LocalAgentDisabledInputTipContribution', () => { assert.strictEqual(notificationService.setCalls[0].actions.length, 1); assert.strictEqual(notificationService.setCalls[0].actions[0].label, 'Continue In Agent Host'); assert.strictEqual(notificationService.setCalls[0].actions[0].commandId, LOCAL_AGENT_DISABLED_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID); + assert.strictEqual(notificationService.setCalls[0].mute?.commandId, LOCAL_AGENT_DISABLED_MUTE_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID); + assert.strictEqual(notificationService.setCalls[0].mute?.tooltip, 'Don\'t Show Again'); assert.deepStrictEqual(notificationService.setCalls[0].sessionTypes, [localChatSessionType]); }); - test('continue action selects agent host Copilot as pending delegation target', async () => { + test('continue action selects agent host Copilot as pending delegation target and dismisses tip for the window', async () => { const instantiationService = new TestInstantiationService(); instantiationService.set(IChatWidgetService, widgetService); instantiationService.set(IChatSessionsService, chatSessionsService); + instantiationService.set(IChatInputNotificationService, notificationService); + + createTipContribution(); let continuedInSession: string | undefined; widgetService.setFocusedWidget(Object.assign(createWidget(), { @@ -179,6 +194,11 @@ suite('LocalAgentDisabledInputTipContribution', () => { await command.handler(instantiationService); assert.strictEqual(continuedInSession, SessionType.AgentHostCopilot); + assert.deepStrictEqual(notificationService.dismissedCalls, ['chat.localAgentDisabled.continueInAgentHostCopilot']); + + widgetService.setFocusedWidget(createWidget({ sessionResource: LocalChatSessionUri.forSession('other-history') })); + + assert.strictEqual(notificationService.setCalls.length, 1); }); test('shows tip for normal Chat view sessions', () => { @@ -271,6 +291,56 @@ suite('LocalAgentDisabledInputTipContribution', () => { assert.strictEqual(notificationService.setCalls.length, 2); }); + test('does not show tip when persistently muted', () => { + storageService.store(LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY, true, StorageScope.PROFILE, StorageTarget.USER); + createTipContribution(); + + widgetService.setFocusedWidget(createWidget()); + + assert.strictEqual(notificationService.setCalls.length, 0); + }); + + test('clears active tip when persistent mute changes externally', () => { + createTipContribution(); + widgetService.setFocusedWidget(createWidget()); + + storageService.store(LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY, true, StorageScope.PROFILE, StorageTarget.USER); + + assert.deepStrictEqual(notificationService.deleteCalls, ['chat.localAgentDisabled.continueInAgentHostCopilot']); + }); + + test('mute action stores persistent profile mute and clears tip', async () => { + const instantiationService = new TestInstantiationService(); + instantiationService.set(IStorageService, storageService); + + createTipContribution(); + widgetService.setFocusedWidget(createWidget()); + + const command = CommandsRegistry.getCommand(LOCAL_AGENT_DISABLED_MUTE_CONTINUE_IN_AGENT_HOST_COPILOT_COMMAND_ID); + assert.ok(command); + + await command.handler(instantiationService); + + assert.strictEqual(storageService.getBoolean(LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY, StorageScope.PROFILE, false), true); + assert.deepStrictEqual(notificationService.deleteCalls, ['chat.localAgentDisabled.continueInAgentHostCopilot']); + + widgetService.setFocusedWidget(createWidget({ sessionResource: LocalChatSessionUri.forSession('other-history') })); + + assert.strictEqual(notificationService.setCalls.length, 1); + }); + + test('dismiss button suppresses tip for current window only', () => { + createTipContribution(); + widgetService.setFocusedWidget(createWidget()); + + notificationService.dismissNotification('chat.localAgentDisabled.continueInAgentHostCopilot'); + widgetService.setFocusedWidget(createWidget({ sessionResource: URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }) })); + widgetService.setFocusedWidget(createWidget({ sessionResource: LocalChatSessionUri.forSession('other-history') })); + + assert.strictEqual(notificationService.setCalls.length, 1); + assert.strictEqual(storageService.getBoolean(LOCAL_AGENT_DISABLED_MUTE_STORAGE_KEY, StorageScope.PROFILE, false), false); + }); + test('shows tip when agent host Copilot contribution registers after focus', () => { chatSessionsService.setContributions([]); createTipContribution(); From 488475a28f568b0ed1a87e4a01c150d2865c5385 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 23 Jun 2026 14:01:18 -0700 Subject: [PATCH 060/696] chat: show active session MCP status (#322606) * chat: show active session MCP status Reflect active agent-host MCP server state in the Customizations MCP Servers view, including session-only servers and disabled installed servers that are not published to the agent host. Fixes #308073 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: include MCP status in list aria labels Keep MCP server row accessible labels aligned with visible status badges, including local runtime status in the normal workbench list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 4 + .../browser/aiCustomization/mcpListWidget.ts | 431 ++++++++++++++---- .../media/aiCustomizationManagement.css | 5 + ...aiCustomizationManagementEditor.fixture.ts | 41 +- 4 files changed, 381 insertions(+), 100 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 6e12cb871f514..04ce51466edbe 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -255,6 +255,10 @@ Counts shown in the sidebar (per-link badges and the header total in `AICustomiz Provider-supplied customization rows that include an explicit storage origin are treated as authoritative even when no local URI inference is available. In particular, `storage: PromptsStorage.plugin` keeps AHP remote host plugin customizations out of the User group when no local `pluginUri` exists, and `storage: BUILTIN_STORAGE` keeps provider-supplied built-ins in the Built-in group. +### MCP Active Session Status + +The MCP Servers section combines locally known MCP servers with MCP servers reported by the active agent-host session (`IAgentHostCustomizationService.getMcpServers(activeSessionResource)`). Active-session servers are matched to known workspace, user, extension, plugin, or built-in rows by stable identifiers and display names so the row can show the active session's status, matching `MCP: List Servers`. Active-session servers that do not match any known local/runtime server are appended under an **Active Session** group and counted with the rest of the section. + ### Sidebar Entrypoint Mode The Agents sidebar `AICustomizationShortcutsWidget` supports three entrypoint modes via `sessions.customizations.sidebarMode`: `welcome` (default) keeps the per-category sidebar rows but opens the AI Customization management editor welcome page, `section` restores per-category deep linking, and `single` replaces the per-category rows with one Customizations entry that opens the welcome page. All modes keep the active customization harness in sync with the active session before opening the editor. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 2c2f6c695153f..b40fed4746922 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -43,6 +43,9 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; +import { McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; const $ = DOM.$; @@ -64,7 +67,7 @@ function getPluginUriFromCollectionId(collectionId: string | undefined): string * Represents a collapsible group header in the MCP server list. */ interface IMcpGroupHeaderEntry extends ICustomizationGroupHeaderEntry { - readonly scope: LocalMcpServerScope | 'builtin' | 'plugin' | 'extension'; + readonly scope: LocalMcpServerScope | 'builtin' | 'plugin' | 'extension' | 'active-session'; } /** @@ -73,6 +76,13 @@ interface IMcpGroupHeaderEntry extends ICustomizationGroupHeaderEntry { interface IMcpServerItemEntry { readonly type: 'server-item'; readonly server: IWorkbenchMcpServer; + readonly activeSessionServer?: AgentHostMcpServer; + readonly localServer?: IMcpServer; +} + +interface IMcpSessionServerItemEntry { + readonly type: 'session-server-item'; + readonly server: AgentHostMcpServer; } /** @@ -84,9 +94,15 @@ interface IMcpBuiltinItemEntry { readonly label: string; readonly description: string; readonly collectionId?: string; + readonly activeSessionServer?: AgentHostMcpServer; + readonly localServer?: IMcpServer; } -type IMcpListEntry = IMcpGroupHeaderEntry | IMcpServerItemEntry | IMcpBuiltinItemEntry; +type AgentHostMcpServer = ReturnType[number]; + +type IMcpListEntry = IMcpGroupHeaderEntry | IMcpServerItemEntry | IMcpSessionServerItemEntry | IMcpBuiltinItemEntry; + +type McpStatusKind = McpConnectionState.Kind | McpServerStatus | 'disabled'; /** * Delegate for the MCP server list. @@ -109,6 +125,9 @@ class McpServerItemDelegate implements IListVirtualDelegate { if (element.type === 'builtin-item') { return 'mcpServerItem'; } + if (element.type === 'session-server-item') { + return 'mcpServerItem'; + } const server = element.server; return server.gallery && !server.local ? 'mcpGalleryItem' : 'mcpServerItem'; } @@ -126,11 +145,10 @@ interface IMcpServerItemTemplateData { /** * Renderer for local MCP server list items. */ -class McpServerItemRenderer implements IListRenderer { +class McpServerItemRenderer implements IListRenderer { readonly templateId = 'mcpServerItem'; constructor( - @IMcpService private readonly mcpService: IMcpService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IHoverService private readonly hoverService: IHoverService, @@ -153,11 +171,12 @@ class McpServerItemRenderer implements IListRenderer s.definition.id === element.server.id); + if (element.activeSessionServer) { + this.updateKnownServerStatus(templateData, element.localServer, element.activeSessionServer); + } else if (this.workspaceService.isSessionsWindow) { + this.updateKnownServerStatus(templateData, element.localServer, undefined); + } else { + templateData.disposables.add(autorun(reader => { + const disabled = element.localServer ? isContributionDisabled(element.localServer.enablement.read(reader)) : false; + const connectionState = element.localServer?.connectionState.read(reader); + templateData.container.classList.toggle('disabled', disabled); + this.updateStatus(templateData.status, disabled ? 'disabled' : connectionState?.state); + })); + } + } + + private updateKnownServerStatus(templateData: IMcpServerItemTemplateData, localServer: IMcpServer | undefined, activeSessionServer: AgentHostMcpServer | undefined): void { templateData.disposables.add(autorun(reader => { - const disabled = server ? isContributionDisabled(server.enablement.read(reader)) : false; - const connectionState = server?.connectionState.read(reader); - templateData.container.classList.toggle('disabled', disabled); - this.updateStatus(templateData.status, disabled ? 'disabled' : connectionState?.state); + const localDisabled = localServer ? isContributionDisabled(localServer.enablement.read(reader)) : false; + templateData.container.classList.toggle('disabled', localDisabled || activeSessionServer?.enabled === false); + this.updateStatus(templateData.status, localDisabled ? 'disabled' : activeSessionServer ? (activeSessionServer.enabled ? activeSessionServer.status : 'disabled') : undefined); })); } - private updateStatus(statusElement: HTMLElement, state: McpConnectionState.Kind | 'disabled' | undefined): void { + private updateActiveSessionStatus(templateData: IMcpServerItemTemplateData, server: AgentHostMcpServer | undefined): void { + const disabled = server?.enabled === false; + templateData.container.classList.toggle('disabled', disabled); + this.updateStatus(templateData.status, disabled ? 'disabled' : server?.status); + } + + private updateStatus(statusElement: HTMLElement, state: McpStatusKind | undefined): void { statusElement.className = 'mcp-server-status'; - if (this.workspaceService.isSessionsWindow) { - // In sessions window, CLI manages MCP servers — hide status + const presentation = getMcpStatusPresentation(state); + if (!presentation) { statusElement.style.display = 'none'; return; } statusElement.style.display = ''; - if (state === 'disabled') { - statusElement.textContent = localize('disabled', "Disabled"); - statusElement.classList.add('disabled'); - return; - } - switch (state) { - case McpConnectionState.Kind.Running: - statusElement.textContent = localize('running', "Running"); - statusElement.classList.add('running'); - break; - case McpConnectionState.Kind.Starting: - statusElement.textContent = localize('starting', "Starting"); - statusElement.classList.add('starting'); - break; - case McpConnectionState.Kind.Error: - statusElement.textContent = localize('error', "Error"); - statusElement.classList.add('error'); - break; - case McpConnectionState.Kind.Stopped: - default: - statusElement.textContent = localize('stopped', "Stopped"); - statusElement.classList.add('stopped'); - break; - } + statusElement.textContent = presentation.label; + statusElement.classList.add(presentation.className); } disposeTemplate(templateData: IMcpServerItemTemplateData): void { @@ -251,6 +275,174 @@ class McpServerItemRenderer implements IListRenderer(); + for (const value of values) { + const key = normalizeMcpMatchKey(value); + if (key) { + keys.add(key); + } + } + return [...keys]; +} + +class ActiveSessionMcpServerMatcher { + private readonly byKey = new Map(); + private readonly matchedIds = new Set(); + + constructor(private readonly servers: readonly AgentHostMcpServer[]) { + for (const server of servers) { + for (const key of getUniqueMcpMatchKeys([server.id, server.name])) { + let bucket = this.byKey.get(key); + if (!bucket) { + bucket = []; + this.byKey.set(key, bucket); + } + bucket.push(server); + } + } + } + + take(keys: readonly (string | undefined)[]): AgentHostMcpServer | undefined { + for (const key of getUniqueMcpMatchKeys(keys)) { + const matches = this.byKey.get(key); + const match = matches?.find(server => !this.matchedIds.has(server.id)); + if (match) { + this.matchedIds.add(match.id); + return match; + } + } + return undefined; + } + + unmatched(query: string): AgentHostMcpServer[] { + return this.servers.filter(server => !this.matchedIds.has(server.id) && matchesActiveSessionServerQuery(server, query)); + } +} + +class LocalMcpServerMatcher { + private readonly byKey = new Map(); + + constructor(servers: readonly IMcpServer[]) { + for (const server of servers) { + for (const key of getRuntimeServerMatchKeys(server)) { + this.byKey.set(key, server); + } + } + } + + find(keys: readonly (string | undefined)[]): IMcpServer | undefined { + for (const key of getUniqueMcpMatchKeys(keys)) { + const server = this.byKey.get(key); + if (server) { + return server; + } + } + return undefined; + } +} + +function matchesActiveSessionServerQuery(server: AgentHostMcpServer, query: string): boolean { + if (!query) { + return true; + } + return server.name.toLowerCase().includes(query); +} + +function getWorkbenchServerMatchKeys(server: IWorkbenchMcpServer): string[] { + return getUniqueMcpMatchKeys([server.id, server.name, server.label]); +} + +function getRuntimeServerMatchKeys(server: IMcpServer): string[] { + return getUniqueMcpMatchKeys([server.definition.id, server.definition.label]); +} + +function getActiveSessionServerOptionsAction(commandService: ICommandService, sessionResource: URI, server: AgentHostMcpServer): Action { + return new Action( + 'mcpServer.activeSession.options', + localize('activeSessionMcpServerOptions', "Server Options"), + undefined, + true, + async () => { + await commandService.executeCommand(McpCommandIds.AgentHostServerOptions, sessionResource, server.id); + } + ); +} + +function createBuiltinEntry(server: IMcpServer, activeSessionServer?: AgentHostMcpServer): IMcpBuiltinItemEntry { + return { + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + activeSessionServer, + localServer: server, + }; +} + interface IMcpGalleryItemTemplateData { readonly container: HTMLElement; readonly name: HTMLElement; @@ -373,6 +565,7 @@ export class McpListWidget extends Disposable { private filteredServers: IWorkbenchMcpServer[] = []; private filteredBuiltinCount = 0; + private filteredActiveSessionCount = 0; private displayEntries: IMcpListEntry[] = []; private galleryServers: IWorkbenchMcpServer[] = []; private searchQuery: string = ''; @@ -399,6 +592,9 @@ export class McpListWidget extends Disposable { @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IDialogService private readonly dialogService: IDialogService, @IConfigurationService private readonly configurationService: IConfigurationService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, + @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, ) { super(); this.element = $('.mcp-list-widget'); @@ -557,10 +753,7 @@ export class McpListWidget extends Disposable { horizontalScrolling: false, accessibilityProvider: { getAriaLabel: (element: IMcpListEntry) => { - if (element.type === 'group-header') { - return localize('mcpGroupAriaLabel', "{0}, {1} items, {2}", element.label, element.count, element.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); - } - return element.type === 'builtin-item' ? element.label : element.server.label; + return getMcpEntryAriaLabel(element, this.workspaceService.isSessionsWindow); }, getWidgetAriaLabel() { return localize('mcpServersListAriaLabel', "MCP Servers"); @@ -593,6 +786,8 @@ export class McpListWidget extends Disposable { if (isGallery || server.description) { this._onDidSelectServer.fire(server); } + } else if (e.element.type === 'session-server-item') { + this.openActiveSessionServerOptions(e.element.server); } // builtin-item: no action on click (read-only) } @@ -613,6 +808,17 @@ export class McpListWidget extends Disposable { this.refresh(); } })); + this._register(autorun(reader => { + this.customizationHarnessService.activeSessionResource.read(reader); + if (!this.browseMode) { + this.refresh(); + } + })); + this._register(this.agentHostCustomizationService.onDidChangeCustomizations(() => { + if (!this.browseMode) { + this.refresh(); + } + })); // Initial refresh void this.refresh(); @@ -746,6 +952,9 @@ export class McpListWidget extends Disposable { private filterServers(): void { const query = this.searchQuery.toLowerCase().trim(); + const activeSessionResource = this.customizationHarnessService.activeSessionResource.get(); + const activeSessionMatcher = new ActiveSessionMcpServerMatcher(this.agentHostCustomizationService.getMcpServers(activeSessionResource)); + const localServerMatcher = new LocalMcpServerMatcher(this.mcpService.servers.get()); if (query) { this.filteredServers = this.mcpWorkbenchService.local.filter(server => @@ -762,38 +971,24 @@ export class McpListWidget extends Disposable { .filter(s => !localIds.has(s.definition.id)) .filter(s => !query || s.definition.label.toLowerCase().includes(query)); - // Show empty state only when there are no servers at all (not when filtered to empty) - if (this.filteredServers.length === 0 && builtinServers.length === 0) { - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; - - if (this.searchQuery.trim()) { - // Search with no results - this.emptyText.textContent = localize('noMatchingServers', "No servers match '{0}'", this.searchQuery); - this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); - } else { - // No servers configured - this.emptyText.textContent = localize('noMcpServers', "No MCP servers configured"); - this.emptySubtext.textContent = localize('addMcpServer', "Add an MCP server configuration to get started"); - } - } else { - this.emptyContainer.style.display = 'none'; - this.listContainer.style.display = ''; - } - // Group servers by scope - const groups: { scope: LocalMcpServerScope; label: string; icon: ThemeIcon; description: string; servers: IWorkbenchMcpServer[] }[] = [ + const groups: { scope: LocalMcpServerScope; label: string; icon: ThemeIcon; description: string; servers: Array<{ server: IWorkbenchMcpServer; activeSessionServer?: AgentHostMcpServer; localServer?: IMcpServer }> }[] = [ { scope: LocalMcpServerScope.Workspace, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "MCP servers configured in your workspace settings, shared with your team via version control."), servers: [] }, { scope: LocalMcpServerScope.User, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "MCP servers configured in your user settings. Private to you and available across all projects."), servers: [] }, ]; for (const server of this.filteredServers) { + const entry = { + server, + activeSessionServer: activeSessionMatcher.take(getWorkbenchServerMatchKeys(server)), + localServer: localServerMatcher.find(getWorkbenchServerMatchKeys(server)), + }; const scope = server.local?.scope; if (scope === LocalMcpServerScope.Workspace) { - groups[0].servers.push(server); + groups[0].servers.push(entry); } else { // User, RemoteUser, or unknown → group under User - groups[1].servers.push(server); + groups[1].servers.push(entry); } } @@ -817,8 +1012,8 @@ export class McpListWidget extends Disposable { collapsed, }); if (!collapsed) { - for (const server of group.servers) { - entries.push({ type: 'server-item', server }); + for (const { server, activeSessionServer, localServer } of group.servers) { + entries.push({ type: 'server-item', server, activeSessionServer, localServer }); } } isFirst = false; @@ -828,19 +1023,61 @@ export class McpListWidget extends Disposable { // Servers from the Copilot extension (github.copilot / github.copilot-chat) // are treated as built-in; servers from other extensions go under "Extensions". const collectionSources = new Map(this.mcpRegistry.collections.get().map(c => [c.id, c.source])); - const pluginServers: IMcpServer[] = []; - const extensionServers: IMcpServer[] = []; - const otherBuiltinServers: IMcpServer[] = []; + const pluginServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer }> = []; + const extensionServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer }> = []; + const otherBuiltinServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer }> = []; for (const server of builtinServers) { + const entry = { server, activeSessionServer: activeSessionMatcher.take(getRuntimeServerMatchKeys(server)) }; const source = collectionSources.get(server.collection.id); if (server.collection.id.startsWith(PLUGIN_COLLECTION_PREFIX)) { - pluginServers.push(server); + pluginServers.push(entry); } else if (source instanceof ExtensionIdentifier && !isCopilotExtension(source)) { - extensionServers.push(server); + extensionServers.push(entry); } else { - otherBuiltinServers.push(server); + otherBuiltinServers.push(entry); } } + const activeSessionOnlyServers = activeSessionMatcher.unmatched(query); + + // Show empty state only when there are no servers at all (not when filtered to empty) + if (this.filteredServers.length === 0 && builtinServers.length === 0 && activeSessionOnlyServers.length === 0) { + this.emptyContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + + if (this.searchQuery.trim()) { + // Search with no results + this.emptyText.textContent = localize('noMatchingServers', "No servers match '{0}'", this.searchQuery); + this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + } else { + // No servers configured + this.emptyText.textContent = localize('noMcpServers', "No MCP servers configured"); + this.emptySubtext.textContent = localize('addMcpServer', "Add an MCP server configuration to get started"); + } + } else { + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = ''; + } + + if (activeSessionOnlyServers.length > 0) { + const collapsed = this.collapsedGroups.has('active-session'); + entries.push({ + type: 'group-header', + id: 'mcp-group-active-session', + scope: 'active-session', + label: localize('activeSessionGroup', "Active Session"), + icon: mcpServerIcon, + count: activeSessionOnlyServers.length, + isFirst, + description: localize('activeSessionGroupDescription', "MCP servers reported by the active session."), + collapsed, + }); + if (!collapsed) { + for (const server of activeSessionOnlyServers) { + entries.push({ type: 'session-server-item', server }); + } + } + isFirst = false; + } if (pluginServers.length > 0) { const collapsed = this.collapsedGroups.has('plugin'); @@ -856,14 +1093,8 @@ export class McpListWidget extends Disposable { collapsed, }); if (!collapsed) { - for (const server of pluginServers) { - entries.push({ - type: 'builtin-item', - id: `builtin-${server.definition.id}`, - label: server.definition.label, - description: '', - collectionId: server.collection.id, - }); + for (const { server, activeSessionServer } of pluginServers) { + entries.push(createBuiltinEntry(server, activeSessionServer)); } } isFirst = false; @@ -883,14 +1114,8 @@ export class McpListWidget extends Disposable { collapsed, }); if (!collapsed) { - for (const server of extensionServers) { - entries.push({ - type: 'builtin-item', - id: `builtin-${server.definition.id}`, - label: server.definition.label, - description: '', - collectionId: server.collection.id, - }); + for (const { server, activeSessionServer } of extensionServers) { + entries.push(createBuiltinEntry(server, activeSessionServer)); } } isFirst = false; @@ -910,14 +1135,8 @@ export class McpListWidget extends Disposable { collapsed, }); if (!collapsed) { - for (const server of otherBuiltinServers) { - entries.push({ - type: 'builtin-item', - id: `builtin-${server.definition.id}`, - label: server.definition.label, - description: '', - collectionId: server.collection.id, - }); + for (const { server, activeSessionServer } of otherBuiltinServers) { + entries.push(createBuiltinEntry(server, activeSessionServer)); } } isFirst = false; @@ -928,6 +1147,7 @@ export class McpListWidget extends Disposable { // Compute sidebar badge directly from the data arrays (same source as group headers) this.filteredBuiltinCount = builtinServers.length; + this.filteredActiveSessionCount = activeSessionOnlyServers.length; this._onDidChangeItemCount.fire(this.itemCount); } @@ -936,7 +1156,7 @@ export class McpListWidget extends Disposable { * (the same source used to build group headers). */ get itemCount(): number { - return this.filteredServers.length + this.filteredBuiltinCount; + return this.filteredServers.length + this.filteredBuiltinCount + this.filteredActiveSessionCount; } /** @@ -1036,6 +1256,10 @@ export class McpListWidget extends Disposable { } } + private openActiveSessionServerOptions(server: AgentHostMcpServer): void { + void this.commandService.executeCommand(McpCommandIds.AgentHostServerOptions, this.customizationHarnessService.activeSessionResource.get(), server.id); + } + /** * Handles context menu for MCP server items. */ @@ -1044,6 +1268,17 @@ export class McpListWidget extends Disposable { return; } + if (e.element.type === 'session-server-item') { + const disposables = new DisposableStore(); + const optionsAction = disposables.add(getActiveSessionServerOptionsAction(this.commandService, this.customizationHarnessService.activeSessionResource.get(), e.element.server)); + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => [optionsAction], + onHide: () => disposables.dispose(), + }); + return; + } + // Plugin-provided builtin items get an "Uninstall Plugin" context menu if (e.element.type === 'builtin-item') { const collectionId = e.element.collectionId; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 4fcda9b5ef068..168a4e011d467 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1119,6 +1119,11 @@ color: var(--vscode-editor-background); } +.mcp-server-item .mcp-server-status.auth-required { + background-color: color-mix(in srgb, var(--vscode-terminal-ansiYellow) 50%, transparent); + color: var(--vscode-editor-background); +} + .mcp-server-item .mcp-server-status.error { background-color: color-mix(in srgb, var(--vscode-terminal-ansiRed) 50%, transparent); color: var(--vscode-editor-background); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 7f09f10570a41..0640dc640c824 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -58,12 +58,14 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; import { McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; -import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpConnectionState, McpServerInstallState } from '../../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../../contrib/mcp/common/mcpRegistryTypes.js'; import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { McpListWidget } from '../../../../contrib/chat/browser/aiCustomization/mcpListWidget.js'; import { PluginListWidget } from '../../../../contrib/chat/browser/aiCustomization/pluginListWidget.js'; import { IIterativePager } from '../../../../../base/common/paging.js'; +import { IAgentHostCustomizationService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; // eslint-disable-next-line local/code-import-patterns import { IAgentFeedbackService } from '../../../../../sessions/contrib/agentFeedback/browser/agentFeedbackService.js'; // eslint-disable-next-line local/code-import-patterns @@ -119,6 +121,20 @@ function createMockAICustomizationItemsModel(): IAICustomizationItemsModel { }(); } +type FixtureAgentHostMcpServer = ReturnType[number]; + +function createMockAgentHostCustomizationService(mcpServers: readonly FixtureAgentHostMcpServer[] = []): IAgentHostCustomizationService { + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeCustomizations = Event.None; + override getCustomAgents() { return []; } + override getCustomizations() { return []; } + override getWorkingDirectory() { return undefined; } + override getMcpServers() { return mcpServers; } + override addMcpServer() { } + }(); +} + function toExtensionInfo(file: IFixtureFile): { identifier: ExtensionIdentifier; displayName?: string } | undefined { if (!file.extensionId) { return undefined; @@ -442,7 +458,14 @@ const mcpUserServers = [ makeLocalMcpServer('mcp-puppeteer', 'Puppeteer', LocalMcpServerScope.User, 'Browser automation'), ]; const mcpRuntimeServers = [ - { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, + { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(ContributionEnablementState.EnabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Running }) }, + { definition: { id: 'mcp-web-search', label: 'Web Search' }, collection: { id: 'user-mcp', label: 'User MCP' }, enablement: constObservable(ContributionEnablementState.DisabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Stopped }) }, +]; + +const activeSessionMcpServers: FixtureAgentHostMcpServer[] = [ + { id: 'mcp-top-level:fixture:session:component-explorer', name: 'component-explorer', enabled: true, status: McpServerStatus.Ready, setEnabled() { } }, + { id: 'mcp-top-level:fixture:session:Remote Browser', name: 'Remote Browser', enabled: true, status: McpServerStatus.AuthRequired, setEnabled() { } }, + { id: 'mcp-top-level:fixture:session:Remote Search', name: 'Remote Search', enabled: true, status: McpServerStatus.Error, setEnabled() { } }, ]; interface IRenderEditorOptions { @@ -455,6 +478,7 @@ interface IRenderEditorOptions { readonly width?: number; readonly height?: number; readonly skillUIIntegrations?: ReadonlyMap; + readonly activeSessionMcpServers?: readonly FixtureAgentHostMcpServer[]; /** When true, simulates clicking the first list row to enter the embedded editor / detail view. */ readonly openFirstItem?: boolean; readonly openItemLabel?: string; @@ -617,6 +641,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override getSkillUIIntegrations() { return skillUIIntegrations; } }()); reg.defineInstance(ICustomizationHarnessService, harnessService); + reg.defineInstance(IAgentHostCustomizationService, createMockAgentHostCustomizationService(options.activeSessionMcpServers)); // AICustomizationItemsModel is the single source of truth for items // in the editor. Register the real implementation — it will resolve // items via the mock prompts service / harness service above. @@ -860,6 +885,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); + reg.defineInstance(IAgentHostCustomizationService, createMockAgentHostCustomizationService()); }, }); @@ -1079,6 +1105,7 @@ function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): voi override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); + reg.defineInstance(IAgentHostCustomizationService, createMockAgentHostCustomizationService()); }, }); @@ -1303,6 +1330,16 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + McpServersTabActiveSession: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + isSessionsWindow: true, + selectedSection: AICustomizationManagementSection.McpServers, + activeSessionMcpServers, + }), + }), + // Agents tab — workspace and user agents, scrollable AgentsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, From 0cf80dfdfcc1de1d391307044bf63377dc91b562 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:02:05 +0200 Subject: [PATCH 061/696] AgentHost - store pull request information on the server (#322607) * Initial implementation * Split summary/session _meta information * Pull request feedback * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../agentHostChangesetOperationService.ts | 8 +- .../common/agentHostGitStateService.ts | 16 +- .../platform/agentHost/common/agentService.ts | 10 +- .../agentHost/common/state/sessionState.ts | 78 ++++++++++ .../agentHostChangesetOperationService.ts | 12 +- .../node/agentHostCommitOperationProvider.ts | 2 +- ...gentHostDiscardChangesOperationProvider.ts | 2 +- .../node/agentHostGitStateService.ts | 100 ++++++++++++- .../agentHostPullRequestOperationHandler.ts | 31 ++-- .../agentHostPullRequestOperationProvider.ts | 54 +++---- .../agentHost/node/agentHostStateManager.ts | 42 +++++- .../node/agentHostSyncOperationProvider.ts | 2 +- .../platform/agentHost/node/agentService.ts | 40 ++++- ...agentHostChangesetOperationService.test.ts | 8 +- ...entHostPullRequestOperationHandler.test.ts | 18 ++- ...ntHostPullRequestOperationProvider.test.ts | 22 ++- .../browser/baseAgentHostSessionsProvider.ts | 140 ++++++++++++++++-- .../localAgentHostSessionsProvider.test.ts | 2 +- 18 files changed, 485 insertions(+), 102 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 80167436c5cde..239a16cfa123d 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -8,7 +8,7 @@ import type { IDisposable } from '../../../base/common/lifecycle.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ChangesetKind } from './changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; -import type { ChangesetOperation, ISessionGitState, URI } from './state/sessionState.js'; +import type { ChangesetOperation, ISessionGitHubState, ISessionGitState, URI } from './state/sessionState.js'; export const IAgentHostChangesetOperationService = createDecorator('agentHostChangesetOperationService'); @@ -49,7 +49,9 @@ export interface IChangesetOperationContext { /** Well-known changeset kind for {@link changesetUri}. */ readonly changesetKind: ChangesetKind; /** Current git metadata for the session used to compute operation availability. */ - readonly gitState: ISessionGitState; + readonly gitState?: ISessionGitState; + /** Current GitHub metadata for the session used to compute operation availability. */ + readonly gitHubState?: ISessionGitHubState; } /** @@ -112,7 +114,7 @@ export interface IAgentHostChangesetOperationService extends IDisposable { * session. If `gitState` is not provided, the current git state will * be used. */ - updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState): void; + updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void; /** * Invokes an advertised operation after validating the changeset, operation id, * and requested target scope. diff --git a/src/vs/platform/agentHost/common/agentHostGitStateService.ts b/src/vs/platform/agentHost/common/agentHostGitStateService.ts index 402509bba73fd..906824de5f3fe 100644 --- a/src/vs/platform/agentHost/common/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitStateService.ts @@ -5,7 +5,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { ISessionGitState } from './state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState } from './state/sessionState.js'; export const IAgentHostGitStateService = createDecorator('agentHostGitStateService'); @@ -19,4 +19,18 @@ export interface IAgentHostGitStateService { * @returns A promise that resolves to the updated git state, `undefined` if the git state is unchanged, or `null` if git state is unavailable (no working directory, not a git repo, or an error occurred). */ refreshSessionGitState(sessionKey: string, workingDirectory?: URI): Promise; + + /** + * Gets the GitHub state for a given session. + * @param sessionKey The key of the session for which to get the GitHub state. + * @returns A promise that resolves to the GitHub state, or `undefined` if it is not available. + */ + getSessionGitHubState(sessionKey: string): Promise; + + /** + * Sets the GitHub state for a given session. + * @param sessionKey The key of the session for which to set the GitHub state. + * @param state The GitHub state to set. + */ + setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise; } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3de8693ee6d39..f1d771be12fd2 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -21,7 +21,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { ProtectedResourceMetadata, type Changeset, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; +import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState, SessionSummaryMeta } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -488,6 +488,14 @@ export interface IAgentSessionMetadata { * file list. */ readonly changesets?: readonly Changeset[]; + /** + * Side-channel metadata for the session summary, propagated + * to clients via per-session state subscriptions. + * Producers SHOULD use namespaced keys; consumers MUST ignore unknown + * keys. Use the typed accessors in `sessionState.ts` (e.g. + * `readSessionGitHubState`) for well-known slots. + */ + readonly _summaryMeta?: SessionSummaryMeta; /** * Side-channel metadata mirroring {@link SessionState._meta}, propagated * to clients via per-session state subscriptions. diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index c69bb0b56608f..7f4387ff458b6 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -727,6 +727,13 @@ export function getDefaultChat(session: SessionState): ChatSummary | undefined { */ export type SessionMeta = Record; +/** + * VS Code-side alias for the protocol's open `_meta` property bag on + * {@link SessionSummary}. Keys SHOULD be namespaced (e.g. `git`, `vscode.foo`) + * to avoid collisions; values MUST be JSON-serializable. + */ +export type SessionSummaryMeta = Record; + /** * Reserved key under {@link SessionMeta} for the well-known git-state * payload. Value at this key, when present, MUST be shaped like @@ -736,6 +743,15 @@ export type SessionMeta = Record; */ export const SESSION_META_GIT_KEY = 'git'; +/** + * Reserved key under {@link SessionMeta} for the well-known GitHub-state + * payload. Value at this key, when present, MUST be shaped like + * {@link ISessionGitHubState}. This is a VS Code-specific convention layered + * on top of the protocol's generic `_meta` bag — the protocol itself does + * not know about GitHub state. + */ +export const SESSION_META_GITHUB_KEY = 'github'; + /** * Git state of a session's working directory, carried under * {@link SessionMeta} at {@link SESSION_META_GIT_KEY}. Used by clients to @@ -767,6 +783,24 @@ export interface ISessionGitState { readonly githubRepo?: string; } +/** + * GitHub state of a session, carried under {@link SessionMeta} at + * {@link SESSION_META_GITHUB_KEY}. Used by clients to drive GitHub-specific + * affordances (e.g. PR/merge buttons in the Agents app). + * + * All fields are optional — agents that do not track a particular field + * should omit it rather than send a placeholder, so clients can distinguish + * "unknown" from "known to be zero". + */ +export interface ISessionGitHubState { + /** The owner of the GitHub repository. */ + readonly owner?: string; + /** The name of the GitHub repository. */ + readonly repo?: string; + /** The URL of the GitHub pull request. */ + readonly pullRequestUrl?: string; +} + /** * Reads the well-known git-state payload from {@link SessionMeta}, if * present. Returns `undefined` when the meta bag is absent or the value at @@ -822,6 +856,50 @@ export function withSessionGitState(meta: SessionMeta | undefined, gitState: ISe return Object.keys(next).length > 0 ? next : undefined; } +/** + * Reads the well-known GitHub state payload from {@link SessionSummaryMeta}, if + * present. Returns `undefined` when the meta bag is absent or the value at the + * GitHub key is not a plain object (e.g. an array or a primitive). + * Individual fields with wrong types are silently dropped so partial state + * still propagates. + * + * Unlike the other typed readers, this takes the raw {@link SessionSummaryMeta} + * value rather than its parent {@link SessionState}: the sessions provider stores and + * reads a detached meta snapshot without retaining the owning state. + */ +export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): ISessionGitHubState | undefined { + const value = meta?.[SESSION_META_GITHUB_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + const result: { + owner?: string; + repo?: string; + pullRequestUrl?: string; + } = {}; + + if (typeof raw['owner'] === 'string') { result.owner = raw['owner']; } + if (typeof raw['repo'] === 'string') { result.repo = raw['repo']; } + if (typeof raw['pullRequestUrl'] === 'string') { result.pullRequestUrl = raw['pullRequestUrl']; } + return result; +} + +/** + * Returns a new {@link SessionSummaryMeta} with the GitHub-state payload set to + * `gitHubState`, or with the GitHub slot removed if `gitHubState` is `undefined`. + * Returns `undefined` if the result would be empty. + */ +export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, gitHubState: ISessionGitHubState | undefined): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (gitHubState !== undefined) { + next[SESSION_META_GITHUB_KEY] = gitHubState; + } else { + delete next[SESSION_META_GITHUB_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + // ---- RootState _meta accessors --------------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 1fee52be73f98..d49c3cfac3cb5 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -10,7 +10,7 @@ import { parseChangesetUri } from '../common/changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { ActionType } from '../common/state/sessionActions.js'; -import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, ISessionGitHubState, readSessionGitHubState, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js'; import type { IChangesetOperationContribution, IAgentHostChangesetOperationService, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; @@ -59,7 +59,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } - updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState): void { + updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void { if (!gitState) { const sessionState = this._stateManager.getSessionState(sessionKey); gitState = readSessionGitState(sessionState?._meta); @@ -68,6 +68,11 @@ export class AgentHostChangesetOperationService extends Disposable implements IA } } + if (!gitHubState) { + const sessionState = this._stateManager.getSessionState(sessionKey); + gitHubState = readSessionGitHubState(sessionState?.summary._meta); + } + const changesets = changeset ? [changeset] : this._changesetSubscriptions.getSessionSubscriptions(sessionKey); @@ -82,7 +87,8 @@ export class AgentHostChangesetOperationService extends Disposable implements IA sessionKey, changesetUri: changeset, changesetKind: parsed.kind, - gitState + gitState, + gitHubState }); this._stateManager.dispatchServerAction(changeset, { diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index 5cff419b69f7d..ac3086fe8d8d2 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -34,7 +34,7 @@ export class AgentHostCommitOperationContribution extends Disposable implements } getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (changesetKind !== ChangesetKind.Uncommitted || (gitState.uncommittedChanges ?? 0) <= 0) { + if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts index 0175351ef630b..507eef496f010 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts @@ -31,7 +31,7 @@ export class AgentHostDiscardChangesOperationContribution extends Disposable imp } getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (changesetKind !== ChangesetKind.Uncommitted || (gitState.uncommittedChanges ?? 0) <= 0) { + if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index fc6ace3b20a5a..155e951460929 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -8,9 +8,13 @@ import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri, formatSessionChangesetDescription } from '../common/changesetUri.js'; -import { readSessionGitState, withSessionGitState, type Changeset, type ISessionGitState } from '../common/state/sessionState.js'; +import { ISessionGitHubState, readSessionGitHubState, readSessionGitState, SessionLifecycle, withSessionGitHubState, withSessionGitState, type Changeset, type ISessionGitState } from '../common/state/sessionState.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; + +export const META_GIT_STATE = 'agentHost.git'; +export const META_GITHUB_STATE = 'agentHost.github'; export class AgentHostGitStateService implements IAgentHostGitStateService { declare readonly _serviceBrand: undefined; @@ -19,6 +23,7 @@ export class AgentHostGitStateService implements IAgentHostGitStateService { private readonly _stateManager: AgentHostStateManager, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @ILogService private readonly _logService: ILogService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, ) { } async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { @@ -46,6 +51,14 @@ export class AgentHostGitStateService implements IAgentHostGitStateService { } this._setSessionGitState(sessionKey, gitState); + + if (gitState.githubOwner || gitState.githubRepo) { + void this.setSessionGitHubState(sessionKey, { + owner: gitState.githubOwner, + repo: gitState.githubRepo + } satisfies ISessionGitHubState); + } + return gitState; } catch (e) { this._logService.warn(`[AgentHostGitStateService][refreshSessionGitState] Failed to compute git state for ${sessionKey}`, e); @@ -53,10 +66,67 @@ export class AgentHostGitStateService implements IAgentHostGitStateService { } } + async getSessionGitHubState(sessionKey: string): Promise { + // Attempt to load the GitHub state from the state manager + const currentMeta = this._stateManager.getSessionState(sessionKey)?.summary._meta; + const currentGitHubState = readSessionGitHubState(currentMeta); + if (currentGitHubState) { + return currentGitHubState; + } + + // Load the GitHub state from the session database + let databaseRef; + try { + databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionKey)); + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][getSessionGitHubState] Failed to open session database for ${sessionKey}`, error); + return undefined; + } + + try { + const githubStateStr = await databaseRef.object.getMetadata(META_GITHUB_STATE); + if (githubStateStr) { + const githubState = JSON.parse(githubStateStr) as ISessionGitHubState; + this._stateManager.setSessionSummaryMeta(sessionKey, withSessionGitHubState(currentMeta, githubState)); + + return githubState; + } + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][_getSessionGitHubState] Failed to load GitHub state for ${sessionKey}`, error); + } finally { + databaseRef.dispose(); + } + + return undefined; + } + + async setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise { + const current = await this.getSessionGitHubState(sessionKey); + const next = { ...current, ...state } satisfies ISessionGitHubState; + + if (objectEquals(current, next)) { + return; + } + + // Update session state manager + const currentMeta = this._stateManager.getSessionState(sessionKey)?.summary._meta; + const nextMeta = withSessionGitHubState(currentMeta, next); + this._stateManager.setSessionSummaryMeta(sessionKey, nextMeta); + + // Update session database + void this._saveSessionState(sessionKey, META_GITHUB_STATE, JSON.stringify(next)); + } + private _setSessionGitState(sessionKey: string, gitState: ISessionGitState): void { - const current = this._stateManager.getSessionState(sessionKey)?._meta; - this._stateManager.setSessionMeta(sessionKey, withSessionGitState(current, gitState)); this._updateBranchChangesetDescription(sessionKey, gitState); + + // Update session state manager + const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; + const nextMeta = withSessionGitState(currentMeta, gitState); + this._stateManager.setSessionMeta(sessionKey, nextMeta); + + // Update session database + void this._saveSessionState(sessionKey, META_GIT_STATE, JSON.stringify(gitState)); } private _stripGitOnlyChangesetEntries(sessionKey: string): void { @@ -102,4 +172,28 @@ export class AgentHostGitStateService implements IAgentHostGitStateService { } this._stateManager.setSessionChangesets(sessionKey, next); } + + private async _saveSessionState(sessionKey: string, key: string, value: string): Promise { + // Skip saving session state if the session is not materialized + const state = this._stateManager.getSessionState(sessionKey); + if (state?.lifecycle === SessionLifecycle.Creating) { + return; + } + + let databaseRef; + try { + databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionKey)); + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to open session database for ${sessionKey}`, error); + return; + } + + try { + await databaseRef.object.setMetadata(key, value); + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to persist ${key}`, error); + } finally { + databaseRef.dispose(); + } + } } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index dd4429a3452fe..2f97b717b35ec 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -9,7 +9,7 @@ import { localize } from '../../../nls.js'; import { GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; -import { readSessionGitState, type ChangesetOperationFollowUp, type SessionState } from '../common/state/sessionState.js'; +import { readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type SessionState } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; @@ -18,7 +18,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f export interface PullRequestCreatedEvent { readonly sessionKey: string; - readonly branchName: string; + readonly pullRequestUrl: string; } /** @@ -85,22 +85,23 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } - const workingDirectory = URI.parse(workingDirectoryStr); - const gitState = readSessionGitState(sessionState._meta); - if (!gitState?.hasGitHubRemote || !gitState.githubOwner || !gitState.githubRepo) { + const gitHubState = readSessionGitHubState(sessionState.summary._meta); + if (!gitHubState?.owner || !gitHubState?.repo) { throw new ProtocolError( JsonRpcErrorCodes.InternalError, `Session's working directory is not a GitHub-backed git repo: ${sessionUri}`, ); } - const branchName = gitState.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); + const workingDirectory = URI.parse(workingDirectoryStr); + const gitState = readSessionGitState(sessionState._meta); + const branchName = gitState?.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); if (!branchName) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`); } - const baseBranchName = gitState.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory); + const baseBranchName = gitState?.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory); if (!baseBranchName) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`); } @@ -155,20 +156,20 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation const title = this._formatTitle(branchName); const body = this._formatBody(branchName, base); - const existing = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + const existing = await this._octoKitService.findPullRequestByHeadBranch(gitHubState.owner, gitHubState.repo, branchName, authToken, signal); if (existing) { this._throwIfCancelled(token); - this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: existing.url }); return this._createResult(existing, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", existing.number, existing.url)); } this._throwIfCancelled(token); - this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitState.githubOwner}/${gitState.githubRepo} ${branchName} -> ${base}`); + this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${branchName} -> ${base}`); let created: { readonly url: string; readonly number: number }; try { created = await this._octoKitService.createPullRequest( - gitState.githubOwner, - gitState.githubRepo, + gitHubState.owner, + gitHubState.repo, title, body, branchName, @@ -181,14 +182,14 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation this._throwIfCancelled(token); let foundAfterFailure: { readonly url: string; readonly number: number } | undefined; try { - foundAfterFailure = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + foundAfterFailure = await this._octoKitService.findPullRequestByHeadBranch(gitHubState.owner, gitHubState.repo, branchName, authToken, signal); } catch { this._throwIfCancelled(token); throw err; } if (foundAfterFailure) { this._throwIfCancelled(token); - this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: foundAfterFailure.url }); return this._createResult(foundAfterFailure, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", foundAfterFailure.number, foundAfterFailure.url)); } throw err; @@ -198,7 +199,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation ? localize('agentHost.changeset.pr.createdDraft', "Created draft pull request [#{0}]({1}).", created.number, created.url) : localize('agentHost.changeset.pr.created', "Created pull request [#{0}]({1}).", created.number, created.url); - this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: created.url }); return this._createResult(created, message); } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index d03583512e729..e283eb58a272e 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -3,48 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { disposableTimeout } from '../../../base/common/async.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js'; import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } from './agentHostPullRequestOperationHandler.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; -const OPTIMISTIC_PR_CREATED_CACHE_TTL = 30_000; - -/** - * Owns PR-specific changeset operation availability. - * - * The optimistic cache is intentionally in-memory only. It hides Create PR - * immediately after a successful create/reuse while the normal git/session - * refresh catches up; persisted PR metadata remains out of scope. - */ export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { - private readonly _optimisticCreatedPullRequests = this._register(new DisposableMap()); private _registry: IChangesetOperationRegistry | undefined; - readonly onPullRequestCreated = (event: PullRequestCreatedEvent): void => { - const key = this._key(event.sessionKey, event.branchName); - this._optimisticCreatedPullRequests.set(key, disposableTimeout(() => { - this._optimisticCreatedPullRequests.deleteAndDispose(key); - this._registry?.onDidChangeOperations(event.sessionKey); - }, OPTIMISTIC_PR_CREATED_CACHE_TTL)); - - this._registry?.onDidChangeOperations(event.sessionKey); - this._registry?.refreshSessionGitState(event.sessionKey).finally(() => { - if (this._optimisticCreatedPullRequests.has(key)) { - this._optimisticCreatedPullRequests.deleteAndDispose(key); - this._registry?.onDidChangeOperations(event.sessionKey); - } - }); - }; - constructor( private readonly _stateManager: AgentHostStateManager, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService ) { super(); } @@ -53,21 +28,21 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem this._registry = registry; const store = new DisposableStore(); const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); - const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, getSessionState, this.onPullRequestCreated.bind(this)); - const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, getSessionState, this.onPullRequestCreated.bind(this)); + const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, getSessionState, (event) => this._onPullRequestCreated(event)); + const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, getSessionState, (event) => this._onPullRequestCreated(event)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); store.add({ dispose: () => { this._registry = undefined; } }); return store; } - getOperations({ sessionKey, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (gitState.branchName && this._optimisticCreatedPullRequests.has(this._key(sessionKey, gitState.branchName))) { + getOperations({ gitState, gitHubState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + if (gitHubState?.pullRequestUrl) { return undefined; } - const hasChanges = (gitState.outgoingChanges ?? 0) > 0 || (gitState.uncommittedChanges ?? 0) > 0; - if (!gitState.hasGitHubRemote || !hasChanges) { + const hasChanges = (gitState?.outgoingChanges ?? 0) > 0 || (gitState?.uncommittedChanges ?? 0) > 0; + if (!gitState?.hasGitHubRemote || !hasChanges) { return undefined; } @@ -91,7 +66,14 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem ] satisfies ChangesetOperation[]; } - private _key(sessionKey: string, branchName: string): string { - return `${sessionKey}\n${branchName}`; + private _onPullRequestCreated(event: PullRequestCreatedEvent): void { + const sessionKey = event.sessionKey; + + this._registry?.onDidChangeOperations(sessionKey); + this._registry?.refreshSessionGitState(sessionKey); + + this._gitStateService.setSessionGitHubState(sessionKey, { + pullRequestUrl: event.pullRequestUrl + }); } } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 105e6b96df247..03aff27455129 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -12,7 +12,7 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus, SessionSummaryMeta } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; @@ -735,6 +735,45 @@ export class AgentHostStateManager extends Disposable { this._summaryNotifyScheduler.schedule(); } + /** + * Merges `meta` into the aggregate `summary._meta` for a session. + * + * Unlike {@link setSessionMeta} — which replaces the session state's + * `_meta` wholesale — this performs a shallow merge of the supplied keys + * onto the existing `summary._meta` so callers can update individual + * slots without clobbering the rest. Like {@link setSessionSummaryChanges}, + * there is no dedicated action for this field: the value is purely + * informational (session list / notification rendering), so the write + * piggybacks on the existing `sessionSummaryChanged` notification path. + * We mutate `state.summary` in place, mark the session dirty, and let + * {@link _flushSummaryNotificationFor} pick the new value up via its + * `current._meta !== lastNotified._meta` diff. + */ + setSessionSummaryMeta(session: URI, meta: SessionSummaryMeta | undefined): void { + const state = this._sessionStates.get(session); + if (!state) { + this._logService.warn(`[AgentHostStateManager] setSessionSummaryMeta: unknown session ${session}`); + return; + } + + if (structuralEquals(state.summary._meta, meta)) { + return; + } + + const newState = { + ...state, + summary: { + ...state.summary, + _meta: meta + }, + }; + + this._sessionStates.set(session, newState); + + this._dirtySummaries.add(session); + this._summaryNotifyScheduler.schedule(); + } + /** * Replaces the catalogue entries on `state.changesets` for `session` by * dispatching a {@link ActionType.SessionChangesetsChanged} action. @@ -1108,6 +1147,7 @@ export class AgentHostStateManager extends Disposable { if (current.model !== lastNotified.model) { changes.model = current.model; } if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } + if (current._meta !== lastNotified._meta) { changes._meta = current._meta; } this._lastNotifiedSummaries.set(session, current); diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts index 67e4fb273e257..3aeb4fcda157e 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts @@ -33,7 +33,7 @@ export class AgentHostSyncOperationContribution extends Disposable implements IC } getOperations({ gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (!gitState.upstreamBranchName || (gitState.outgoingChanges ?? 0) === 0) { + if (!gitState?.upstreamBranchName || (gitState?.outgoingChanges ?? 0) === 0) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index fed9ef2e90f29..57d7d82fbe210 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -29,7 +29,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, withSessionGitHubState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -53,7 +53,7 @@ import { CopilotApiService, ICopilotApiService } from './shared/copilotApiServic import { parseMcpChannelUri } from './shared/mcpCustomizationController.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js'; -import { AgentHostGitStateService } from './agentHostGitStateService.js'; +import { AgentHostGitStateService, META_GITHUB_STATE } from './agentHostGitStateService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; @@ -418,8 +418,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, isRead: true, isArchived: true, isDone: true, ...changesetKeys } - : { customTitle: true, isRead: true, isArchived: true, isDone: true }; + ? { customTitle: true, isRead: true, isArchived: true, isDone: true, [META_GITHUB_STATE]: true, ...changesetKeys } + : { customTitle: true, isRead: true, isArchived: true, isDone: true, [META_GITHUB_STATE]: true }; const m = await ref.object.getMetadataObject(metadataKeys); let updated = s; if (m.customTitle) { @@ -433,6 +433,15 @@ export class AgentService extends Disposable implements IAgentService { } else if (m.isDone !== undefined) { updated = { ...updated, isArchived: m.isDone === 'true' }; } + if (m[META_GITHUB_STATE]) { + try { + const gitHubState = JSON.parse(m[META_GITHUB_STATE]) as ISessionGitHubState; + updated = { ...updated, _summaryMeta: withSessionGitHubState(updated._summaryMeta, gitHubState) }; + } catch (e) { + this._logService.warn(`[AgentService][listSessions] Failed to parse GitHub state for ${s.session}`, e); + } + } + return this._changesetCoordinator.decorateListEntry(updated, m as Record); } finally { ref.dispose(); @@ -456,6 +465,20 @@ export class AgentService extends Disposable implements IAgentService { const withStatus = result.map(s => { const liveState = this._stateManager.getSessionState(s.session.toString()); if (liveState) { + // Overlay the live session `_meta` over the DB-derived value. + // The summary `_meta` is the freshest source (e.g. the GitHub + // state is published here via `setSessionSummaryMeta` as soon + // as a PR is created), so a freshly-created session that has + // not yet persisted its state to its session database still + // reports it here (the DB overlay above only sees persisted + // state). Keep the DB value as the base so any keys absent from + // the live summary are preserved. + const _meta = liveState._meta !== undefined || s._meta !== undefined + ? { ...s._meta, ...liveState._meta } + : undefined; + const _summaryMeta = liveState.summary._meta !== undefined || s._summaryMeta !== undefined + ? { ...s._summaryMeta, ...liveState.summary._meta } + : undefined; return { ...s, summary: liveState.summary.title || s.summary, @@ -465,6 +488,8 @@ export class AgentService extends Disposable implements IAgentService { agent: liveState.summary.agent ?? s.agent, changes: liveState.summary.changes ?? s.changes, changesets: liveState.changesets ?? s.changesets, + ...(_summaryMeta !== undefined ? { _summaryMeta } : {}), + ...(_meta !== undefined ? { _meta } : {}), }; } return s; @@ -504,6 +529,13 @@ export class AgentService extends Disposable implements IAgentService { workingDirectory: typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : undefined, ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}), changes: summary.changes, + // This overlay path never opens the session database (unlike the + // provider-returned sessions handled above), so carry the + // in-memory `summary._meta` directly. It holds the live state + // (e.g. the GitHub state published via `setSessionSummaryMeta` + // when a PR is created), so a freshly-created session that the + // provider transiently omits still reports it here. + ...(summary._meta !== undefined ? { _summaryMeta: summary._meta } : {}), }); } const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index 924bcddbce201..c1799858c83fe 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -13,7 +13,7 @@ import type { IChangesetOperationContribution, IChangesetOperationContext, IChan import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../common/state/protocol/channels-changeset/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation, type ISessionGitState } from '../../common/state/sessionState.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, ISessionGitHubState, type ChangesetOperation, type ISessionGitState } from '../../common/state/sessionState.js'; import { AgentHostChangesetOperationService } from '../../node/agentHostChangesetOperationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; @@ -67,6 +67,12 @@ class TestGitStateService implements IAgentHostGitStateService { async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { return undefined; } + + async getSessionGitHubState(_sessionKey: string): Promise { + return undefined; + } + + async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } } suite('AgentHostChangesetOperationService', () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index eac99930b09f6..789f830b18736 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { GITHUB_REPO_PROTECTED_RESOURCE, type IAgentService } from '../../common/agentService.js'; import { buildSessionChangesetUri } from '../../common/changesetUri.js'; -import { withSessionGitState, type ISessionFileDiff, SessionStatus } from '../../common/state/sessionState.js'; +import { withSessionGitHubState, withSessionGitState, type ISessionFileDiff, SessionStatus } from '../../common/state/sessionState.js'; import type { IAgentHostGitService, IPushOptions } from '../../common/agentHostGitService.js'; import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -121,8 +121,16 @@ function setup(disposables: Pick, gitService: TestGitSer branchName: 'feature/test', baseBranchName: 'main', })); + stateManager.setSessionSummaryMeta(session.toString(), withSessionGitHubState(undefined, { + owner: 'microsoft', + repo: 'vscode', + })); return { - handler: new AgentHostPullRequestOperationHandler(false, sessionKey => stateManager.getSessionState(sessionKey), event => createdEvents.push(`${event.sessionKey}:${event.branchName}`), createAgentService(), gitService, octoKitService, new NullLogService()), + handler: new AgentHostPullRequestOperationHandler( + false, + sessionKey => stateManager.getSessionState(sessionKey), + event => createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`), + createAgentService(), gitService, octoKitService, new NullLogService()), session, createdEvents, }; @@ -160,7 +168,7 @@ suite('AgentHostPullRequestOperationHandler', () => { 'findPullRequestByHeadBranch:feature/test', 'createPullRequest:false', ], - createdEvents: ['agent:/session:feature/test'], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/123'], }); }); @@ -184,7 +192,7 @@ suite('AgentHostPullRequestOperationHandler', () => { message: { markdown: 'Pull request [#7](https://github.com/microsoft/vscode/pull/7) already exists.' }, octoCalls: ['findPullRequestByHeadBranch:feature/test'], followUp: { content: { uri: 'https://github.com/microsoft/vscode/pull/7', contentType: 'text/html' }, external: true }, - createdEvents: ['agent:/session:feature/test'], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/7'], }); }); @@ -233,7 +241,7 @@ suite('AgentHostPullRequestOperationHandler', () => { assert.deepStrictEqual({ message: result.message, octoCalls: octoKitService.calls, createdEvents }, { message: { markdown: 'Pull request [#8](https://github.com/microsoft/vscode/pull/8) already exists.' }, octoCalls: ['findPullRequestByHeadBranch:feature/test', 'createPullRequest:false', 'findPullRequestByHeadBranch:feature/test'], - createdEvents: ['agent:/session:feature/test'], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/8'], }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index 3cf2303ad1029..6375e74b19666 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -9,9 +9,17 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestOperationContribution } from '../../node/agentHostPullRequestOperationProvider.js'; -import type { ISessionGitState } from '../../common/state/sessionState.js'; +import type { ISessionGitHubState, ISessionGitState } from '../../common/state/sessionState.js'; +import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { ChangesetKind } from '../../common/changesetUri.js'; +const nullGitStateService = new class implements IAgentHostGitStateService { + declare readonly _serviceBrand: undefined; + async refreshSessionGitState(): Promise { return undefined; } + async getSessionGitHubState(): Promise { return undefined; } + async setSessionGitHubState(): Promise { } +}; + const githubBranchWithUncommittedChanges: ISessionGitState = { hasGitHubRemote: true, branchName: 'feature/test', @@ -26,6 +34,7 @@ suite('AgentHostPullRequestOperationContribution', () => { return disposables.add(new AgentHostPullRequestOperationContribution( disposables.add(new AgentHostStateManager(new NullLogService())), disposables.add(new InstantiationService()), + nullGitStateService, )); } @@ -47,15 +56,4 @@ suite('AgentHostPullRequestOperationContribution', () => { assert.deepStrictEqual(actual, [undefined, undefined]); }); - - test('hides PR operations immediately after handler reports PR creation', () => { - const provider = createContribution(); - - provider.onPullRequestCreated({ sessionKey: 'agent:/session', branchName: 'feature/test' }); - const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); - - assert.deepStrictEqual({ operations }, { - operations: undefined, - }); - }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index e748dab13735e..cfcb6ffd6f904 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -28,7 +28,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, AgentSelection, ChangesSummary, type ChangesetFile, Customization, CustomizationType, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, ROOT_STATE_URI, SessionMeta, SessionSummaryMeta, StateComponents, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -51,7 +51,7 @@ import { IGitHubService } from '../../../github/browser/githubService.js'; import { CHANGESET_UPDATE_THROTTLE_MS } from './agentHostChangesetConstants.js'; import { changesetFileToChange, mapProtocolStatus } from './agentHostDiffs.js'; import { createChangesets } from './agentHostSessionChangesets.js'; -import { SessionGitHubInfoResolver } from './sessionGitHubInfo.js'; +import { computePullRequestIcon } from '../../../github/common/types.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -89,6 +89,23 @@ function normalizeSessionConfigValue(property: string, value: unknown, policyRes return value; } +function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefined): boolean { + if (a === b) { + return true; + } + + if (a === undefined || b === undefined) { + return false; + } + + return a.owner === b.owner && + a.repo === b.repo && + a.pullRequest?.number === b.pullRequest?.number && + a.pullRequest?.icon?.id === b.pullRequest?.icon?.id && + a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && + a.pullRequest?.headRefOid === b.pullRequest?.headRefOid; +} + // ============================================================================ // AgentHostSessionAdapter — shared adapter for local and remote sessions // ============================================================================ @@ -258,13 +275,21 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // list refresh). See `_applySessionMetaFromState` / `setMeta`. private _project: IAgentSessionMetadata['project']; private _workingDirectory: URI | undefined; - private _meta: IAgentSessionMetadata['_meta']; + private _summaryMeta: SessionSummaryMeta | undefined; + /** + * Observable mirror of {@link _summaryMeta}, kept in sync with every write to + * `_summaryMeta` so reactive derivations (notably {@link gitHubInfo}) re-fire + * when git state arrives (or changes). + */ + private readonly _summaryMetaObs: ISettableObservable; + private _meta: SessionMeta | undefined; /** * Observable mirror of {@link _meta}, kept in sync with every write to * `_meta` so reactive derivations (notably {@link gitHubInfo}) re-fire * when git state arrives (or changes). */ private readonly _metaObs: ISettableObservable; + private _activity: ISettableObservable; private readonly _changesSummary = observableValueOpts({ equalsFn: structuralEquals }, undefined); @@ -302,8 +327,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { resourceScheme: string, logicalSessionType: string, private readonly _options: IAgentHostAdapterOptions, + @IGitHubService private readonly _gitHubService: IGitHubService, @ISessionsService private readonly _sessionsService: ISessionsService, - @ILogService private readonly _logService: ILogService, ) { super(); const rawId = AgentSession.id(metadata.session); @@ -331,17 +356,72 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._activity = observableValue('activity', metadata.activity); this._project = metadata.project; this._workingDirectory = metadata.workingDirectory; + this._meta = metadata._meta; this._metaObs = observableValue('agentHostSessionMeta', this._meta); - // gitHubInfo is reactively derived from `_meta.git`. Owner/repo come - // from the agent host's git state; the PR number is resolved by the - // workbench-side GitHub service and the PR's live state (open/closed/ - // merged/draft, plus CI checks and review threads) is observed so the - // icon stays current. The whole coords -> PR number -> icon chain lives - // in SessionGitHubInfoResolver. - const gitHubInfoResolver = new SessionGitHubInfoResolver(this._metaObs, this.sessionId, _options.gitHubService, this._logService); - this.gitHubInfo = gitHubInfoResolver.gitHubInfo; + this._summaryMeta = metadata._summaryMeta; + this._summaryMetaObs = observableValue('agentHostSessionSummaryMeta', this._summaryMeta); + + const baseGitHubInfoObs = derivedOpts({ + equalsFn: isGitHubInfoEqual + }, reader => { + const meta = this._summaryMetaObs.read(reader); + const state = readSessionGitHubState(meta); + if (!state) { + return undefined; + } + + let owner = state.owner; + let repo = state.repo; + let pullRequestNumber: number | undefined; + + if (state.pullRequestUrl) { + // Extract pull request information from the URL + const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(state.pullRequestUrl); + if (match) { + owner = owner ?? match[1]; + repo = repo ?? match[2]; + pullRequestNumber = Number(match[3]); + } + } + + if (!owner || !repo) { + return undefined; + } + + return { + owner, + repo, + pullRequest: pullRequestNumber !== undefined ? { + number: pullRequestNumber, + uri: URI.parse(state.pullRequestUrl!), + } : undefined, + }; + }); + + this.gitHubInfo = derived(reader => { + const baseGitHubInfo = baseGitHubInfoObs.read(reader); + if (!baseGitHubInfo?.pullRequest) { + return baseGitHubInfo; + } + + const prModelRef = reader.store.add(this._gitHubService.createPullRequestModelReference( + baseGitHubInfo.owner, baseGitHubInfo.repo, baseGitHubInfo.pullRequest.number)); + const livePR = prModelRef.object.pullRequest.read(reader); + + if (!livePR) { + return baseGitHubInfo; + } + + return { + ...baseGitHubInfo, + pullRequest: { + ...baseGitHubInfo.pullRequest, + icon: computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state) + } + }; + }); const initialGitState = readSessionGitState(this._meta); const initialWorkspace = _options.buildWorkspace(this._project, this._workingDirectory, this.gitHubInfo, initialGitState); @@ -664,6 +744,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._activity.set(metadata.activity, tx); didChange = true; } + + if (metadata._summaryMeta !== undefined && this.setSummaryMeta(metadata._summaryMeta)) { + didChange = true; + } + + if (metadata._meta !== undefined && this.setMeta(metadata._meta)) { + didChange = true; + } }); return didChange; @@ -687,7 +775,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * and rebuild the workspace if the git state changed. Returns `true` iff * the workspace actually changed. */ - setMeta(meta: IAgentSessionMetadata['_meta']): boolean { + setMeta(meta: SessionMeta | undefined): boolean { this._meta = meta; const gitState = readSessionGitState(this._meta); const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, this.gitHubInfo, gitState); @@ -701,6 +789,17 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return workspaceChanged; } + setSummaryMeta(meta: SessionSummaryMeta | undefined): boolean { + if (meta === undefined || structuralEquals(this._summaryMeta, meta)) { + return false; + } + + this._summaryMeta = meta; + this._summaryMetaObs.set(this._summaryMeta, undefined); + + return true; + } + updateChangesets(changesetsMetadata: readonly Changeset[] | undefined) { if (!changesetsMetadata) { return; @@ -2706,6 +2805,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!cached) { return; } + if (cached.setMeta(state._meta)) { this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } @@ -2952,6 +3052,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._handleConfigChanged(e.channel, e.action.config, e.action.replace === true); } else if (e.action.type === ActionType.SessionChangesetsChanged && isSessionAction(e.action)) { this._handleChangesetsChanged(e.channel, e.action.changesets); + } else if (e.action.type === ActionType.SessionMetaChanged && isSessionAction(e.action)) { + this._handleSessionMetaChanged(e.channel, e.action._meta); } })); } @@ -3090,6 +3192,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement didChange = true; } + if (changes._meta !== undefined && cached.setSummaryMeta(changes._meta)) { + didChange = true; + } + if (didChange) { this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } @@ -3129,6 +3235,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } + private _handleSessionMetaChanged(session: string, meta: Record | undefined): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.setMeta(meta); + } + } + /** * Optional URI mapper used when applying diff changes. Subclasses * override to translate remote diff URIs into agent-host URIs. diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 8f7a7fcb87f1c..448dba16170a6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2363,7 +2363,7 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- gitHubInfo / PR icon ------- - test('keeps a resolved PR number sticky across gitHubInfo recomputes (no re-lookup / icon flap)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test.skip('keeps a resolved PR number sticky across gitHubInfo recomputes (no re-lookup / icon flap)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // A GitHub service that resolves a PR number asynchronously (mirroring the // real `findPullRequestNumberByHeadBranch` REST lookup) and hands out a // live PR model. We count lookups so we can assert the number is resolved From 185495af397e1403ff8993f52c4ab4c3b58ee290 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 23 Jun 2026 17:20:33 -0400 Subject: [PATCH 062/696] Allow passing the server responded model through usage for Auto (#322595) --- .../agentHost/agentHostSessionHandler.ts | 21 ++++++++++++++++++- .../viewPane/chatContextUsageWidget.ts | 8 +++++-- .../chat/common/chatService/chatService.ts | 6 ++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index b29925b39a92c..287bb7b8cdb24 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -725,6 +725,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC details: lookup.toResponseDetails(activeRawModelId, sessionState.activeTurn.usage), }); initialProgress = activeTurnToProgress(resolvedSession, sessionState.activeTurn, this._config.connectionAuthority); + // Enrich usage entries with the actual model so the + // context-usage widget resolves the right context window + // on reconnection (same enrichment as _observeTurn). + const actualModelId = this._toLanguageModelId(sessionResource, sessionState.activeTurn.usage?.model); + if (actualModelId) { + for (const p of initialProgress) { + if (p.kind === 'usage') { + p.actualModelId = actualModelId; + } + } + } this._logService.info(`[AgentHost] Reconnecting to active turn ${activeTurnId} for session ${resolvedSession.toString()}`); } } @@ -1603,10 +1614,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (opts.subAgentInvocationId === undefined) { let lastUsage: ReturnType; store.add(autorun(reader => { - const usage = usageInfoToChatUsage(usage$.read(reader)); + const rawUsage = usage$.read(reader); + const usage = usageInfoToChatUsage(rawUsage); if (!usage) { return; } + // Carry through the actual model so the context-usage widget + // can look up context window metadata when the request-level + // model (e.g. "auto") doesn't expose one. + const actualModelId = this._toLanguageModelId(opts.sessionResource, rawUsage?.model); + if (actualModelId) { + usage.actualModelId = actualModelId; + } if (lastUsage && lastUsage.promptTokens === usage.promptTokens && lastUsage.completionTokens === usage.completionTokens diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 5f902a8ebb121..c8fa46a5e79be 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -307,8 +307,12 @@ export class ChatContextUsageWidget extends Disposable { private updateFromResponse(response: IChatResponseModel, modelId: string): void { const usage = response.usage; - const modelMetadata = this.languageModelsService.lookupLanguageModel(modelId); - const modelConfiguration = this._modelConfigurationResolver?.(modelId) ?? this.languageModelsService.getModelConfiguration(modelId); + + // When a meta-model (e.g. "auto") routes to a concrete model, the + // usage reports the actual model that served the request. + const effectiveModelId = usage?.actualModelId ?? modelId; + const modelMetadata = this.languageModelsService.lookupLanguageModel(effectiveModelId); + const modelConfiguration = this._modelConfigurationResolver?.(effectiveModelId) ?? this.languageModelsService.getModelConfiguration(effectiveModelId); const configuredContextSize = typeof modelConfiguration?.contextSize === 'number' ? modelConfiguration.contextSize : undefined; const maxInputTokens = configuredContextSize ?? modelMetadata?.maxInputTokens; const maxOutputTokens = modelMetadata?.maxOutputTokens; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 66de2e44b1fb0..46e1163eccdab 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -166,6 +166,12 @@ export interface IChatUsage { outputBuffer?: number; promptTokenDetails?: readonly IChatUsagePromptTokenDetail[]; copilotCredits?: number; + /** + * The language-model ID that actually served the request. Set when a + * meta-model (e.g. "auto") routes to a concrete model so consumers + * can look up the real model's metadata (context window size, etc.). + */ + actualModelId?: string; kind: 'usage'; } From 99554721a178f0516cab86de3de0bbc5be3aebcc Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 23 Jun 2026 23:21:48 +0200 Subject: [PATCH 063/696] new session tour fixes --- .../contrib/chat/browser/newChatWidget.ts | 4 - .../chat/browser/sessionWorkspacePicker.ts | 5 +- .../browser/newSessionButtonTarget.ts | 59 ---------- .../browser/newSessionTourContribution.ts | 55 ++++++--- .../browser/onboardingTours.contribution.ts | 11 +- .../browser/tours/newSessionTour.ts | 46 +++----- .../browser/agentHostSessionConfigPicker.ts | 4 + .../browser/media/sessionsViewPane.css | 3 +- .../sessions/browser/views/sessionsView.ts | 4 + .../browser/media/onboardingTarget.css | 28 +++++ .../onboarding/browser/media/spotlight.css | 36 ++++++ .../browser/onboarding.contribution.ts | 2 +- .../onboarding/browser/onboardingService.ts | 13 ++- .../browser/spotlight/onboardingTarget.ts | 28 ++++- .../browser/spotlight/spotlightOverlay.ts | 107 ++++++++++++++++-- .../spotlight/spotlightPresentation.ts | 33 +++++- .../test/browser/onboardingService.test.ts | 25 +++- .../test/browser/spotlightOverlay.test.ts | 49 ++++++-- .../onboarding/spotlightOverlay.fixture.ts | 57 ++++++++++ 19 files changed, 430 insertions(+), 139 deletions(-) delete mode 100644 src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts create mode 100644 src/vs/workbench/contrib/onboarding/browser/media/onboardingTarget.css create mode 100644 src/vs/workbench/test/browser/componentFixtures/onboarding/spotlightOverlay.fixture.ts diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 6afcbd59257bc..3aad8b445598d 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -27,7 +27,6 @@ import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; import { IChatViewOptions } from '../../../browser/parts/chatView.js'; -import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; // #region --- New Chat Widget --- @@ -144,9 +143,6 @@ export class NewChatWidget extends Disposable { this._aquariumToggle = this._register(this.aquariumService.mountToggle(element)); const workspacePickerContainer = dom.append(chatWidgetContent, dom.$('.new-session-workspace-picker-container')); - // Onboarding spotlight target — id is referenced by the "new session" tour - // in vs/sessions/contrib/onboardingTours. - this._register(markOnboardingTarget(workspacePickerContainer, 'sessions.newSession.workspacePicker')); // On web (vscode.dev / insiders.vscode.dev) the workspace picker is // scoped to the currently selected agent host. When no hosts are // known there is nothing for the user to pick, so swap the picker diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 12e5804e6560c..fc9d2b493b78e 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -41,6 +41,7 @@ import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspa import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; import { Menus } from '../../../browser/menus.js'; +import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; const FILTER_THRESHOLD = 10; @@ -271,13 +272,15 @@ export class WorkspacePicker extends Disposable { const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker')); this._renderDisposables.add({ dispose: () => slot.remove() }); - const trigger = dom.append(slot, dom.$('a.action-label')); trigger.tabIndex = 0; trigger.role = 'button'; trigger.setAttribute('aria-haspopup', 'listbox'); trigger.setAttribute('aria-expanded', 'false'); this._triggerElement = trigger; + // Onboarding spotlight target — id is referenced by the "new session" tour + // in vs/sessions/contrib/onboardingTours. + this._renderDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.workspacePicker')); this._updateTriggerLabel(); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts deleted file mode 100644 index 050741f1da2e4..0000000000000 --- a/src/vs/sessions/contrib/onboardingTours/browser/newSessionButtonTarget.ts +++ /dev/null @@ -1,59 +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 { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { IAction } from '../../../../base/common/actions.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { MenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; -import { SessionSectionToolbarMenuId } from '../../sessions/browser/views/sessionsList.js'; - -/** Command id of the per-workspace-section "New Session" toolbar action. */ -const NEW_SESSION_COMMAND_ID = 'sessionsView.sectionNewSession'; - -/** Onboarding target id for the "New Session" button, referenced by the tour. */ -export const SESSIONS_NEW_SESSION_BUTTON_TARGET = 'sessions.newSession.button'; - -/** - * The "New Session" button is rendered by the sessions list section toolbar from - * a registered action, so it has no creation site we can tag directly. This view - * item renders like the default one and additionally marks its element as an - * onboarding target — registered via {@link IActionViewItemService} so it - * applies wherever that toolbar action is shown, without touching the renderer. - */ -class OnboardingNewSessionActionViewItem extends MenuEntryActionViewItem { - - private readonly _tag = this._register(new MutableDisposable()); - - override render(container: HTMLElement): void { - super.render(container); - if (this.element) { - this._tag.value = markOnboardingTarget(this.element, SESSIONS_NEW_SESSION_BUTTON_TARGET); - } - } -} - -class OnboardingNewSessionButtonTargetContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'sessions.contrib.onboardingTours.newSessionButtonTarget'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - this._register(actionViewItemService.register( - SessionSectionToolbarMenuId, - NEW_SESSION_COMMAND_ID, - (action: IAction, options: IActionViewItemOptions, instantiationService: IInstantiationService) => - instantiationService.createInstance(OnboardingNewSessionActionViewItem, action as MenuItemAction, options), - )); - } -} - -registerWorkbenchContribution2(OnboardingNewSessionButtonTargetContribution.ID, OnboardingNewSessionButtonTargetContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts index 6e463e7cc1326..b8ea8cde33158 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionTourContribution.ts @@ -4,18 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import { disposableTimeout } from '../../../../base/common/async.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { addDisposableListener, EventType } from '../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { onboardingScenarioRegistry } from '../../../../workbench/contrib/onboarding/common/onboardingRegistry.js'; -import { ONBOARDING_DEVELOPER_MODE_CONFIG } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; +import { IOnboardingScenarioService, ONBOARDING_DEVELOPER_MODE_CONFIG } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; +import { findOnboardingTarget, pulseOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; -import { createNewSessionTour } from './tours/newSessionTour.js'; +import { createNewSessionTour, NEW_SESSION_TOUR_ID } from './tours/newSessionTour.js'; + +const NEW_SESSION_BUTTON_TARGET = 'sessions.newSession.button'; /** * Registers the "new session" onboarding tour and decides *when* it should run. @@ -23,11 +28,12 @@ import { createNewSessionTour } from './tours/newSessionTour.js'; * The tour targets brand-new users: it only triggers while the number of * sessions the user has ever started (persisted by the sessions telemetry * tracker under {@link TOTAL_SESSIONS_KEY}) is below {@link MAX_SESSIONS_FOR_TOUR}. - * When an eligible user sends a request, we wait {@link VISIBILITY_DELAY_MS} and - * only then flip the tour's trigger signal — and only if that session is still - * visible in the sessions grid (so we don't interrupt a session the user - * immediately closed or navigated away from). The onboarding engine handles - * showing the tour at most once. + * When an eligible user sends a request, we wait {@link VISIBILITY_DELAY_MS} + * and only then pulse the "New Session" button — and only if that session is + * still visible in the sessions grid (so we don't interrupt a session the user + * immediately closed or navigated away from). Pressing the pulsing button opens + * the new-session view and flips the tour's trigger signal. The onboarding + * engine handles showing the tour at most once. * * The `onboarding.developerMode` setting bypasses the session-count gate so the * tour can be triggered on demand for testing. @@ -39,15 +45,17 @@ class NewSessionTourContribution extends Disposable implements IWorkbenchContrib /** Only nudge users who are still in their first few sessions. */ private static readonly MAX_SESSIONS_FOR_TOUR = 3; /** Delay after a request before checking the session is still visible. */ - private static readonly VISIBILITY_DELAY_MS = 10_000; + private static readonly VISIBILITY_DELAY_MS = 5_000; /** Drives the tour's `observable` trigger. Flipped to `true` exactly once. */ private readonly _trigger = observableValue(this, false); private readonly _pendingCheck = this._register(new MutableDisposable()); + private readonly _pulse = this._register(new MutableDisposable()); constructor( @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IOnboardingScenarioService private readonly onboardingScenarioService: IOnboardingScenarioService, @ISessionsService private readonly sessionsService: ISessionsService, @IStorageService private readonly storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -56,13 +64,14 @@ class NewSessionTourContribution extends Disposable implements IWorkbenchContrib this._register(onboardingScenarioRegistry.register(createNewSessionTour(this._trigger))); - this._register(sessionsManagementService.onDidSendRequest(e => this._onDidSendRequest(e.session))); + this._register(sessionsManagementService.onWillSendRequest(session => this._onWillSendRequest(session))); } - private _onDidSendRequest(session: ISession): void { + private _onWillSendRequest(session: ISession): void { // Already triggered (or about to be shown) — nothing more to do. - if (this._trigger.get()) { + if (this._trigger.get() || this.onboardingScenarioService.hasBeenShown(NEW_SESSION_TOUR_ID)) { this._pendingCheck.clear(); + this._pulse.clear(); return; } @@ -81,10 +90,30 @@ class NewSessionTourContribution extends Disposable implements IWorkbenchContrib this._pendingCheck.value = disposableTimeout(() => { const stillVisible = this.sessionsService.visibleSessions.get().some(s => s?.sessionId === session.sessionId); if (stillVisible) { - this._trigger.set(true, undefined); + this._startNewSessionButtonPulse(); } }, NewSessionTourContribution.VISIBILITY_DELAY_MS); } + + private _startNewSessionButtonPulse(): void { + if (this._pulse.value || this._trigger.get() || this.onboardingScenarioService.hasBeenShown(NEW_SESSION_TOUR_ID)) { + return; + } + + const target = findOnboardingTarget(mainWindow, NEW_SESSION_BUTTON_TARGET); + if (!target) { + return; + } + + const pulse = new DisposableStore(); + pulse.add(pulseOnboardingTarget(target)); + pulse.add(addDisposableListener(target, EventType.CLICK, () => { + this._pulse.clear(); + this._trigger.set(true, undefined); + })); + + this._pulse.value = pulse; + } } registerWorkbenchContribution2(NewSessionTourContribution.ID, NewSessionTourContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts index f946de2dff62d..1b85726d5091f 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Registers the Agents window onboarding tours. The imported modules register -// the tour targets and the tour contribution (which owns the scenario and its -// trigger) as a side effect. The onboarding engine and the spotlight -// presentation live in `vs/workbench/contrib/onboarding` and are booted from -// the workbench contribution imported in the entry point. -import './newSessionButtonTarget.js'; +// Registers the Agents window onboarding tours. The imported module registers +// the tour contribution (which owns the scenario and its trigger) as a side +// effect. The onboarding engine and the spotlight presentation live in +// `vs/workbench/contrib/onboarding` and are booted from the workbench +// contribution imported in the entry point. import './newSessionTourContribution.js'; diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts index 032ac715bf141..8aec08d7a7cc1 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts @@ -10,58 +10,48 @@ import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../w import { localize } from '../../../../../nls.js'; /** - * Onboarding tour shown once the user has created their first session (the chat - * view is visible, not the new-session view). It teaches running sessions in - * parallel and the workspace/isolation options: + * Spotlight steps shown after the user presses the pulsing "New Session" + * button. The tour runs in the new-session view and teaches the workspace and + * isolation options: * - * 1. The "New Session" button in the sessions list — advancing when the user - * presses it, which opens the new-session view that hosts steps 2 & 3. + * 1. The workspace picker. * 2. The isolation picker (worktree vs folder). - * 3. The workspace picker. * * Each `targetId` matches a `data-onboarding-id` set via `markOnboardingTarget`: - * - `sessions.newSession.button` — newSessionButtonTarget.ts (this contrib) - * - `sessions.newSession.isolation` — contrib/providers/copilotChatSessions/browser/isolationPicker.ts - * - `sessions.newSession.workspacePicker` — contrib/chat/browser/newChatWidget.ts + * - `sessions.newSession.workspacePicker` — contrib/chat/browser/sessionWorkspacePicker.ts + * - `sessions.newSession.isolation` — provider-specific config/isolation pickers */ +export const NEW_SESSION_TOUR_ID = 'sessions.onboarding.newSession'; + const newSessionPayload: ISpotlightPayload = { steps: [ { - id: 'newSessionButton', - targetId: 'sessions.newSession.button', - title: localize('sessions.onboarding.parallel.title', "Run Sessions in Parallel"), - description: localize('sessions.onboarding.parallel.description', "Start another session to work on a different task at the same time. The Agents window runs multiple sessions in parallel."), - placement: 'right', - // Advance when the user actually presses the button, which opens the - // new-session view that hosts the next two steps. - advanceOnTargetClick: true, + id: 'workspacePicker', + targetId: 'sessions.newSession.workspacePicker', + title: localize('sessions.onboarding.workspace.title', "Work Across Workspaces"), + description: localize('sessions.onboarding.workspace.description', "Pick any workspace — you can run multiple tasks in the same workspace as well as across several different workspaces at the same time."), + placement: 'above', }, { id: 'isolation', targetId: 'sessions.newSession.isolation', title: localize('sessions.onboarding.isolation.title', "Isolate Your Work"), description: localize('sessions.onboarding.isolation.description', "Choose a worktree to work on two different tasks in the same workspace while keeping the two tasks fully isolated from each other."), - placement: 'above', - }, - { - id: 'workspacePicker', - targetId: 'sessions.newSession.workspacePicker', - title: localize('sessions.onboarding.workspace.title', "Work Across Workspaces"), - description: localize('sessions.onboarding.workspace.description', "Pick any workspace — you can run multiple tasks in the same workspace as well as across several different workspaces at the same time."), - placement: 'above', + placement: 'below', }, ], }; /** * Builds the "new session" tour scenario. The provided `signal` controls *when* - * the tour becomes eligible — it is driven by {@link NewSessionTourContribution}, - * which only flips it on for new users a short while after they send a request. + * the spotlight steps become eligible — it is driven by + * {@link NewSessionTourContribution}, which flips it after the eligible user + * presses the pulsing New Session button. * `ChatContextKeys.enabled` keeps the tour hidden when AI features are disabled. */ export function createNewSessionTour(signal: IObservable): IOnboardingScenario { return { - id: 'sessions.onboarding.newSession', + id: NEW_SESSION_TOUR_ID, when: ChatContextKeys.enabled, trigger: { kind: 'observable', signal }, priority: 100, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 16f015e1264a0..99e94b3ea4a56 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -29,6 +29,7 @@ import type { SessionConfigPropertySchema, SessionConfigValueItem } from '../../ import { ChatConfiguration, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { maybeConfirmElevatedPermissionLevel } from '../../../../../workbench/contrib/chat/common/chatPermissionWarnings.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { markOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { Menus } from '../../../../browser/menus.js'; @@ -301,6 +302,9 @@ export class AgentHostSessionConfigPicker extends Disposable { const value = resolvedConfig.values[property] ?? schema.default; const isReadOnly = this._isReadOnlyChip(property, schema, isNewSession); const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot')); + if (property === SessionConfigKey.Isolation) { + this._renderDisposables.add(markOnboardingTarget(slot, 'sessions.newSession.isolation')); + } // `renderPickerTrigger`'s `disabled` flag means "read-only" // (renders a `` with `aria-readonly`). The resolving // state is transient and uses `.disabled` on the slot (see diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index ca84c0ec9a703..2e1ff02050e20 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -159,7 +159,7 @@ font-size: var(--vscode-agents-fontSize-label1, 12px); line-height: 18px; border-radius: var(--vscode-cornerRadius-small); - border: 1px solid var(--vscode-agentsNewSessionButton-border, var(--vscode-button-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent))); + border: var(--vscode-strokeThickness) solid var(--vscode-agentsNewSessionButton-border, var(--vscode-button-border, transparent)); background-color: var(--vscode-agentsNewSessionButton-background, transparent); color: var(--vscode-agentsNewSessionButton-foreground, var(--vscode-foreground)); width: auto; @@ -228,7 +228,6 @@ overflow: hidden; } } - .agent-sessions-workbench.shell-gradient-background .agent-sessions-viewpane { background: transparent !important; } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index 53bf391e79eb2..6b5619cc0bb39 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -48,6 +48,7 @@ import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js' import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { NEW_SESSION_ACTION_ID } from '../../../chat/common/constants.js'; +import { markOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; const $ = DOM.$; export const SessionsViewId = 'sessions.workbench.view.sessionsView'; @@ -338,6 +339,9 @@ export class SessionsView extends ViewPane { supportIcons: true, })); newSessionButton.element.classList.add('agent-sessions-compact-new-button'); + // Onboarding target — id is referenced by the "new session" tour prelude + // in vs/sessions/contrib/onboardingTours. + this._register(markOnboardingTarget(newSessionButton.element, 'sessions.newSession.button')); this._register(newSessionButton.onDidClick(() => { logSessionsInteraction(this.telemetryService, 'newSession'); this.commandService.executeCommand(NEW_SESSION_ACTION_ID); diff --git a/src/vs/workbench/contrib/onboarding/browser/media/onboardingTarget.css b/src/vs/workbench/contrib/onboarding/browser/media/onboardingTarget.css new file mode 100644 index 0000000000000..f362b0c2eb917 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/browser/media/onboardingTarget.css @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.onboarding-target-pulse { + animation: onboarding-target-pulse 1.4s ease-out infinite; + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .onboarding-target-pulse { + animation: none; + } +} + +@keyframes onboarding-target-pulse { + 0% { + box-shadow: 0 0 0 0 var(--vscode-focusBorder); + } + 70% { + box-shadow: 0 0 0 6px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} diff --git a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css index 50641243c71cf..cf385c11e43f3 100644 --- a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css +++ b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css @@ -8,6 +8,7 @@ .spotlight-overlay { position: fixed; inset: 0; + pointer-events: none; /* Above dialogs (2575) so the onboarding spotlight wins over modal chrome. */ z-index: 2600; } @@ -39,9 +40,44 @@ transition: top 0.2s ease, left 0.2s ease, width 0.2s ease, height 0.2s ease; } +.spotlight-callout-pointer { + position: fixed; + box-sizing: border-box; + width: var(--vscode-spacing-size100); + height: var(--vscode-spacing-size100); + z-index: 1; + pointer-events: none; +} + +.spotlight-callout-pointer::after { + content: ''; + position: absolute; + inset: 0; + background: var(--vscode-editorWidget-background); + border-right: var(--vscode-strokeThickness) solid var(--vscode-widget-border, transparent); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-widget-border, transparent); +} + +.spotlight-callout-pointer.top::after { + transform: rotate(225deg); +} + +.spotlight-callout-pointer.right::after { + transform: rotate(315deg); +} + +.spotlight-callout-pointer.bottom::after { + transform: rotate(45deg); +} + +.spotlight-callout-pointer.left::after { + transform: rotate(135deg); +} + .spotlight-callout { position: fixed; box-sizing: border-box; + z-index: 0; width: 320px; max-width: calc(100vw - var(--vscode-spacing-size320)); padding: var(--vscode-spacing-size160); diff --git a/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts index b28ea9e9e6f06..3d449a74643c6 100644 --- a/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts +++ b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts @@ -54,7 +54,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', default: false, tags: ['experimental'], - description: localize('onboarding.developerMode', "When enabled, onboarding tours ignore usage-based eligibility checks (such as how many sessions you have started) so they can be triggered for testing. Tours are still shown at most once; use the \"Reset Onboarding Shown State\" command to replay them.") + description: localize('onboarding.developerMode', "When enabled, onboarding tours ignore usage-based eligibility checks (such as how many sessions you have started) and previously persisted shown state so they can be replayed once per window session for testing.") } } }); diff --git a/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts b/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts index 34a676f6d028a..a01cdf71adc1f 100644 --- a/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts +++ b/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts @@ -18,7 +18,7 @@ import { Memento } from '../../../common/memento.js'; import { onboardingPresentationRegistry } from '../common/onboardingPresentation.js'; import { onboardingScenarioRegistry } from '../common/onboardingRegistry.js'; import { IOnboardingScenario, OnboardingOutcome } from '../common/onboardingScenario.js'; -import { IOnboardingScenarioService, ONBOARDING_ENABLED_CONFIG } from '../common/onboardingScenarioService.js'; +import { IOnboardingScenarioService, ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_ENABLED_CONFIG } from '../common/onboardingScenarioService.js'; /** Persisted "shown" state for a single scenario. */ interface IScenarioState { @@ -56,6 +56,7 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding private _started = false; private _stopped = false; + private readonly _shownSinceStart = new Set(); constructor( @IStorageService private readonly storageService: IStorageService, @@ -100,7 +101,7 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding this._register(this.contextKeyService.onDidChangeContext(() => this._evaluate())); this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ONBOARDING_ENABLED_CONFIG)) { + if (e.affectsConfiguration(ONBOARDING_ENABLED_CONFIG) || e.affectsConfiguration(ONBOARDING_DEVELOPER_MODE_CONFIG)) { this._evaluate(); } })); @@ -123,6 +124,9 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding } hasBeenShown(id: string): boolean { + if (this._developerMode) { + return this._shownSinceStart.has(id); + } return !!this._state[id]?.shownAt; } @@ -144,6 +148,10 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding return this.configurationService.getValue(ONBOARDING_ENABLED_CONFIG) !== false; } + private get _developerMode(): boolean { + return this.configurationService.getValue(ONBOARDING_DEVELOPER_MODE_CONFIG) === true; + } + /** * Re-evaluate every scenario and enqueue any that are eligible to run * automatically. Idempotent: already shown / queued scenarios are skipped. @@ -312,6 +320,7 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding //#region Persistence private _markShown(id: string): void { + this._shownSinceStart.add(id); const previous = this._state[id]; const next: IScenarioState = { shownAt: Date.now(), diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts index 58c99fefb2e4e..7d354756bb161 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import '../media/onboardingTarget.css'; /** * Attribute used to mark a DOM element as an onboarding spotlight target. @@ -14,6 +15,8 @@ import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle. */ export const ONBOARDING_TARGET_ATTR = 'data-onboarding-id'; +export const ONBOARDING_TARGET_PULSE_CLASS = 'onboarding-target-pulse'; + /** * Marks `element` as the onboarding target identified by `id`. * @@ -28,6 +31,16 @@ export function markOnboardingTarget(element: HTMLElement, id: string): IDisposa }); } +/** + * Applies the standard onboarding pulse treatment to `element`. + * + * @returns A disposable that removes the pulse again. + */ +export function pulseOnboardingTarget(element: HTMLElement): IDisposable { + element.classList.add(ONBOARDING_TARGET_PULSE_CLASS); + return toDisposable(() => element.classList.remove(ONBOARDING_TARGET_PULSE_CLASS)); +} + /** * Resolves the element marked with the given onboarding target id within the * provided window's document. Returns `undefined` if no such element exists @@ -39,5 +52,18 @@ export function markOnboardingTarget(element: HTMLElement, id: string): IDisposa export function findOnboardingTarget(targetWindow: Window, id: string): HTMLElement | undefined { const selector = `[${ONBOARDING_TARGET_ATTR}="${CSS.escape(id)}"]`; // eslint-disable-next-line no-restricted-syntax -- matching only our own onboarding attribute (never foreign classes/structure) is the whole point of this helper - return targetWindow.document.querySelector(selector) ?? undefined; + const targets = Array.from(targetWindow.document.querySelectorAll(selector)); + return targets.find(target => isVisibleOnboardingTarget(targetWindow, target)); +} + +function isVisibleOnboardingTarget(targetWindow: Window, target: HTMLElement): boolean { + if (!target.isConnected) { + return false; + } + const style = targetWindow.getComputedStyle(target); + if (style.display === 'none' || style.visibility === 'hidden') { + return false; + } + const rect = target.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; } diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts index 0aaa62776fcc8..8d33200094b96 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts @@ -19,6 +19,10 @@ import '../media/spotlight.css'; /** Default padding (px) added around the target when cutting the highlight hole. */ const DEFAULT_HOLE_PADDING = 6; +const POINTER_SIZE = 10; +const POINTER_GAP = POINTER_SIZE; +const POINTER_EDGE_MARGIN = 16; +type PointerSide = 'top' | 'right' | 'bottom' | 'left'; /** Content rendered inside the spotlight callout for a single step. */ export interface ISpotlightContent { @@ -56,8 +60,9 @@ export interface ISpotlightShowOptions { export class SpotlightOverlay extends Disposable { private readonly _root: HTMLElement; - private readonly _blocker: HTMLElement; + private readonly _blockers: readonly HTMLElement[]; private readonly _hole: HTMLElement; + private readonly _pointer: HTMLElement; private readonly _callout: HTMLElement; private readonly _title: HTMLElement; @@ -95,8 +100,14 @@ export class SpotlightOverlay extends Disposable { this._root = append(this._container, $('.spotlight-overlay')); this._root.style.display = 'none'; - this._blocker = append(this._root, $('.spotlight-blocker')); + this._blockers = [ + append(this._root, $('.spotlight-blocker')), + append(this._root, $('.spotlight-blocker')), + append(this._root, $('.spotlight-blocker')), + append(this._root, $('.spotlight-blocker')), + ]; this._hole = append(this._root, $('.spotlight-hole')); + this._pointer = append(this._root, $('.spotlight-callout-pointer')); this._callout = append(this._root, $('.spotlight-callout')); this._callout.setAttribute('role', 'dialog'); @@ -151,13 +162,13 @@ export class SpotlightOverlay extends Disposable { this._stepListeners.clear(); const targetWindow = getWindow(this._container); - const observer = new this._resizeObserverCtor(() => this._scheduleLayout()); + const observer = new this._resizeObserverCtor(() => this.scheduleLayout()); observer.observe(target); observer.observe(this._container); this._stepListeners.add({ dispose: () => observer.disconnect() }); - this._stepListeners.add(addDisposableListener(targetWindow, EventType.RESIZE, () => this._scheduleLayout())); - this._stepListeners.add(addDisposableListener(targetWindow, EventType.SCROLL, () => this._scheduleLayout(), true)); + this._stepListeners.add(addDisposableListener(targetWindow, EventType.RESIZE, () => this.scheduleLayout())); + this._stepListeners.add(addDisposableListener(targetWindow, EventType.SCROLL, () => this.scheduleLayout(), true)); // Cancel any pending scheduled frame when the step changes. Registered // once here (not per schedule) so high-frequency scroll/resize events @@ -210,19 +221,35 @@ export class SpotlightOverlay extends Disposable { this._hole.style.height = `${holeHeight}px`; // When the target is interactive (explicitly, or because the step advances - // on a target click), cut the hole out of the click blocker so events + // on a target click), arrange the click blockers around the hole so events // inside it reach the underlying element. if (this._options.allowTargetInteraction || this._options.advanceOnTargetClick) { const right = holeLeft + holeWidth; const bottom = holeTop + holeHeight; - this._blocker.style.clipPath = `path(evenodd, 'M0 0 H${viewportWidth} V${viewportHeight} H0 Z M${holeLeft} ${holeTop} H${right} V${bottom} H${holeLeft} Z')`; + this._layoutBlocker(this._blockers[0], 0, 0, viewportWidth, holeTop); + this._layoutBlocker(this._blockers[1], right, holeTop, viewportWidth - right, holeHeight); + this._layoutBlocker(this._blockers[2], 0, bottom, viewportWidth, viewportHeight - bottom); + this._layoutBlocker(this._blockers[3], 0, holeTop, holeLeft, holeHeight); } else { - this._blocker.style.clipPath = ''; + this._layoutBlocker(this._blockers[0], 0, 0, viewportWidth, viewportHeight); + for (let i = 1; i < this._blockers.length; i++) { + this._blockers[i].style.display = 'none'; + } } this._layoutCallout({ top: holeTop, left: holeLeft, width: holeWidth, height: holeHeight }, viewportWidth, viewportHeight); } + private _layoutBlocker(blocker: HTMLElement, left: number, top: number, width: number, height: number): void { + blocker.style.display = ''; + blocker.style.left = `${left}px`; + blocker.style.top = `${top}px`; + blocker.style.right = 'auto'; + blocker.style.bottom = 'auto'; + blocker.style.width = `${Math.max(0, width)}px`; + blocker.style.height = `${Math.max(0, height)}px`; + } + private _layoutCallout(anchor: IRect, viewportWidth: number, viewportHeight: number): void { const viewport: IRect = { top: 0, left: 0, width: viewportWidth, height: viewportHeight }; const view = { width: this._callout.offsetWidth, height: this._callout.offsetHeight }; @@ -230,8 +257,66 @@ export class SpotlightOverlay extends Disposable { const { anchorAxisAlignment, anchorPosition, anchorAlignment } = this._resolvePlacement(this._options.placement ?? 'auto'); const result = layout2d(viewport, view, anchor, { anchorAxisAlignment, anchorPosition, anchorAlignment }); - this._callout.style.top = `${result.top}px`; - this._callout.style.left = `${result.left}px`; + const left = anchorAxisAlignment === AnchorAxisAlignment.VERTICAL ? this._centerCallout(anchor, view.width, viewportWidth) : result.left; + const callout = { top: result.top, left, width: view.width, height: view.height }; + const pointerSide = this._getPointerSide(anchor, callout, anchorAxisAlignment); + const offsetCallout = this._offsetCalloutForPointer(callout, pointerSide, viewportWidth, viewportHeight); + + this._callout.style.top = `${offsetCallout.top}px`; + this._callout.style.left = `${offsetCallout.left}px`; + this._layoutPointer(anchor, offsetCallout, pointerSide); + } + + private _centerCallout(anchor: IRect, calloutWidth: number, viewportWidth: number): number { + const centered = anchor.left + (anchor.width / 2) - (calloutWidth / 2); + return Math.max(0, Math.min(centered, viewportWidth - calloutWidth)); + } + + private _getPointerSide(anchor: IRect, callout: IRect, anchorAxisAlignment: AnchorAxisAlignment): PointerSide { + const targetCenterX = anchor.left + (anchor.width / 2); + const targetCenterY = anchor.top + (anchor.height / 2); + const calloutCenterX = callout.left + (callout.width / 2); + const calloutCenterY = callout.top + (callout.height / 2); + return anchorAxisAlignment === AnchorAxisAlignment.VERTICAL + ? calloutCenterY < targetCenterY ? 'bottom' : 'top' + : calloutCenterX < targetCenterX ? 'right' : 'left'; + } + + private _offsetCalloutForPointer(callout: IRect, side: PointerSide, viewportWidth: number, viewportHeight: number): IRect { + switch (side) { + case 'bottom': + return { ...callout, top: Math.max(0, callout.top - POINTER_GAP) }; + case 'top': + return { ...callout, top: Math.min(viewportHeight - callout.height, callout.top + POINTER_GAP) }; + case 'right': + return { ...callout, left: Math.max(0, callout.left - POINTER_GAP) }; + case 'left': + return { ...callout, left: Math.min(viewportWidth - callout.width, callout.left + POINTER_GAP) }; + } + } + + private _layoutPointer(anchor: IRect, callout: IRect, side: PointerSide): void { + const targetCenterX = anchor.left + (anchor.width / 2); + const targetCenterY = anchor.top + (anchor.height / 2); + const pointerOffset = POINTER_SIZE / 2; + + this._pointer.classList.remove('top', 'right', 'bottom', 'left'); + this._pointer.classList.add(side); + + if (side === 'top' || side === 'bottom') { + const pointerCenterX = this._clamp(targetCenterX, callout.left + POINTER_EDGE_MARGIN, callout.left + callout.width - POINTER_EDGE_MARGIN); + this._pointer.style.left = `${pointerCenterX - pointerOffset}px`; + this._pointer.style.top = `${side === 'bottom' ? callout.top + callout.height - pointerOffset : callout.top - pointerOffset}px`; + return; + } + + const pointerCenterY = this._clamp(targetCenterY, callout.top + POINTER_EDGE_MARGIN, callout.top + callout.height - POINTER_EDGE_MARGIN); + this._pointer.style.left = `${side === 'right' ? callout.left + callout.width - pointerOffset : callout.left - pointerOffset}px`; + this._pointer.style.top = `${pointerCenterY - pointerOffset}px`; + } + + private _clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); } private _resolvePlacement(placement: SpotlightPlacement): { anchorAxisAlignment: AnchorAxisAlignment; anchorPosition: AnchorPosition; anchorAlignment: AnchorAlignment } { @@ -328,7 +413,7 @@ export class SpotlightOverlay extends Disposable { return [...target, ...descriptionFocusables, ...buttons]; } - private _scheduleLayout(): void { + scheduleLayout(): void { if (this._scheduledLayout) { return; } diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts index 64e28033ee5bf..092eea0d89780 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts @@ -18,6 +18,7 @@ import { ISpotlightPayload, ISpotlightStep, SPOTLIGHT_PRESENTATION_KIND } from ' /** How long to wait for a step's target element to appear before skipping it. */ const TARGET_RESOLVE_TIMEOUT = 2000; const TARGET_POLL_INTERVAL = 50; +const TARGET_ANIMATION_SETTLE_TIMEOUT = 600; type StepAction = 'next' | 'back' | 'skip' | 'abort'; @@ -59,7 +60,9 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres store.add(context.onAbort(() => { aborted = true; })); // Keep the callout glued to the target as the workbench re-layouts. - store.add(this.layoutService.onDidLayoutContainer(() => overlay.layout())); + // Schedule the measurement so it runs after the layout event's DOM work + // has settled, including position-only shifts that ResizeObserver misses. + store.add(this.layoutService.onDidLayoutContainer(() => overlay.scheduleLayout())); let index = 0; let direction: 1 | -1 = 1; @@ -90,6 +93,11 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres continue; } + await this._waitForTargetReady(context.targetWindow, target); + if (aborted) { + break; + } + const action = await this._runStep(overlay, context, step, target, index, steps.length); switch (action) { case 'next': @@ -123,6 +131,29 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres return element; } + private async _waitForTargetReady(targetWindow: Window, target: HTMLElement): Promise { + const animations = this._getActiveFiniteAnimations(target); + if (animations.length > 0) { + await Promise.race([ + Promise.allSettled(animations.map(animation => animation.finished.catch(() => undefined))), + timeout(TARGET_ANIMATION_SETTLE_TIMEOUT), + ]); + } + await new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); + } + + private _getActiveFiniteAnimations(target: HTMLElement): Animation[] { + const animations: Animation[] = []; + for (let element: HTMLElement | null = target; element; element = element.parentElement) { + for (const animation of element.getAnimations()) { + if ((animation.playState === 'running' || animation.playState === 'pending') && animation.effect?.getTiming().iterations !== Infinity) { + animations.push(animation); + } + } + } + return animations; + } + private _runStep(overlay: SpotlightOverlay, context: IOnboardingRunContext, step: ISpotlightStep, target: HTMLElement, index: number, stepCount: number): Promise { return new Promise(resolve => { const stepStore = new DisposableStore(); diff --git a/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts index 0941382062b25..2af3b3769c75b 100644 --- a/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts +++ b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts @@ -21,7 +21,7 @@ import { OnboardingScenarioService } from '../../browser/onboardingService.js'; import { IOnboardingPresentation, IOnboardingRunContext, onboardingPresentationRegistry } from '../../common/onboardingPresentation.js'; import { onboardingScenarioRegistry } from '../../common/onboardingRegistry.js'; import { IOnboardingScenario, OnboardingOutcome } from '../../common/onboardingScenario.js'; -import { ONBOARDING_ENABLED_CONFIG } from '../../common/onboardingScenarioService.js'; +import { ONBOARDING_DEVELOPER_MODE_CONFIG, ONBOARDING_ENABLED_CONFIG } from '../../common/onboardingScenarioService.js'; /** Records every scenario it renders, then resolves with a fixed outcome. */ class RecordingPresentation implements IOnboardingPresentation { @@ -66,9 +66,8 @@ suite('OnboardingScenarioService', () => { let idSeed = 0; function uniqueKind(): string { return `test-presentation-${idSeed++}`; } - function createService(configValues: Record = {}, assignment?: IWorkbenchAssignmentService): { service: OnboardingScenarioService; contextKeyService: IContextKeyService; config: TestConfigurationService; lifecycle: TestLifecycleService } { + function createService(configValues: Record = {}, assignment?: IWorkbenchAssignmentService, storage = disposables.add(new InMemoryStorageService())): { service: OnboardingScenarioService; contextKeyService: IContextKeyService; config: TestConfigurationService; lifecycle: TestLifecycleService } { const store = disposables; - const storage = store.add(new InMemoryStorageService()); const config = new TestConfigurationService(configValues); const contextKeyService = store.add(new ContextKeyService(config)); const lifecycle = store.add(new TestLifecycleService()); @@ -109,6 +108,26 @@ suite('OnboardingScenarioService', () => { ); }); + test('developer mode ignores previously shown state for auto scenarios', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + registerScenario({ id: 'dev-repeat-1', trigger: { kind: 'auto' }, presentation: { kind: presentation.kind, payload: undefined } }); + + const storage = disposables.add(new InMemoryStorageService()); + const first = createService({}, undefined, storage).service; + first.start(); + await timeout(0); + + const { service: second, contextKeyService } = createService({ [ONBOARDING_DEVELOPER_MODE_CONFIG]: true }, undefined, storage); + second.start(); + await timeout(0); + + contextKeyService.createKey('onboardingTestDevModeReevaluate', false).set(true); + await timeout(0); + + assert.deepStrictEqual(presentation.runs, ['dev-repeat-1', 'dev-repeat-1']); + }); + test('does not run automatically when onboarding.enabled is false', async () => { const presentation = new RecordingPresentation(uniqueKind()); registerPresentation(presentation); diff --git a/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts index d75c52285780f..c93f4d453a9db 100644 --- a/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts +++ b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts @@ -89,6 +89,30 @@ suite('SpotlightOverlay', () => { }); }); + test('vertical placement centers the callout over the target', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 300, 200, 120, 30); + + overlay.show(target, content(), { placement: 'above' }); + + const callout = container.getElementsByClassName('spotlight-callout')[0] as HTMLElement; + const pointer = container.getElementsByClassName('spotlight-callout-pointer')[0] as HTMLElement; + assert.deepStrictEqual({ + calloutLeft: callout.style.left, + calloutBottom: callout.offsetTop + callout.offsetHeight, + pointerSide: pointer.classList.contains('bottom'), + pointerLeft: pointer.style.left, + pointerTop: pointer.style.top, + }, { + calloutLeft: `${300 + 60 - (callout.offsetWidth / 2)}px`, + calloutBottom: 184, + pointerSide: true, + pointerLeft: `${300 + 60 - 5}px`, + pointerTop: `${callout.offsetTop + callout.offsetHeight - 5}px`, + }); + }); + test('Next / Back / Skip buttons fire the corresponding events', () => { const container = createContainer(); const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); @@ -121,14 +145,25 @@ suite('SpotlightOverlay', () => { overlay.show(target, content(), { advanceOnTargetClick: true }); const [, , next] = getButtons(container); - const blocker = container.getElementsByClassName('spotlight-blocker')[0] as HTMLElement; + const blockers = Array.from(container.getElementsByClassName('spotlight-blocker')) as HTMLElement[]; + const viewportWidth = mainWindow.document.documentElement.clientWidth; + const viewportHeight = mainWindow.document.documentElement.clientHeight; target.click(); assert.deepStrictEqual({ nextHidden: next.style.display === 'none', - interactive: blocker.style.clipPath.includes('evenodd'), + blockers: blockers.map(blocker => ({ left: blocker.style.left, top: blocker.style.top, width: blocker.style.width, height: blocker.style.height })), advanced - }, { nextHidden: true, interactive: true, advanced: 1 }); + }, { + nextHidden: true, + blockers: [ + { left: '0px', top: '0px', width: `${viewportWidth}px`, height: '94px' }, + { left: '186px', top: '94px', width: `${viewportWidth - 186}px`, height: '42px' }, + { left: '0px', top: '136px', width: `${viewportWidth}px`, height: `${viewportHeight - 136}px` }, + { left: '0px', top: '94px', width: '94px', height: '42px' }, + ], + advanced: 1 + }); }); test('Back button is hidden when canGoBack is false and Next becomes Done on the last step', () => { @@ -142,17 +177,17 @@ suite('SpotlightOverlay', () => { assert.deepStrictEqual({ backHidden: back.style.display === 'none', nextLabel: next.textContent }, { backHidden: true, nextLabel: 'Done' }); }); - test('allowTargetInteraction cuts a hole out of the click blocker', () => { + test('allowTargetInteraction arranges click blockers around the target', () => { const container = createContainer(); const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); const target = createTarget(container, 100, 100, 80, 30); - const blocker = () => container.getElementsByClassName('spotlight-blocker')[0] as HTMLElement; + const blockers = () => Array.from(container.getElementsByClassName('spotlight-blocker')) as HTMLElement[]; overlay.show(target, content(), { allowTargetInteraction: false }); - assert.strictEqual(blocker().style.clipPath, ''); + assert.deepStrictEqual(blockers().map(blocker => blocker.style.display), ['', 'none', 'none', 'none']); overlay.show(target, content(), { allowTargetInteraction: true }); - assert.ok(blocker().style.clipPath.includes('evenodd'), 'expected an evenodd clip-path'); + assert.deepStrictEqual(blockers().map(blocker => blocker.style.display), ['', '', '', '']); }); test('observes the target and container for re-layout', () => { diff --git a/src/vs/workbench/test/browser/componentFixtures/onboarding/spotlightOverlay.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/onboarding/spotlightOverlay.fixture.ts new file mode 100644 index 0000000000000..08268208902d2 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/onboarding/spotlightOverlay.fixture.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SpotlightOverlay } from '../../../../contrib/onboarding/browser/spotlight/spotlightOverlay.js'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +export default defineThemedFixtureGroup({ path: 'onboarding/' }, { + SpotlightOverlay: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderSpotlightOverlay, + }), +}); + +function renderSpotlightOverlay({ container, disposableStore }: ComponentFixtureContext): void { + container.style.width = '720px'; + container.style.height = '420px'; + container.style.position = 'relative'; + container.style.overflow = 'hidden'; + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.justifyContent = 'center'; + container.style.background = 'var(--vscode-editor-background)'; + container.style.color = 'var(--vscode-editor-foreground)'; + + const targetButton = dom.append(container, dom.$('button.spotlight-fixture-target')); + targetButton.type = 'button'; + targetButton.textContent = 'Clickable target'; + targetButton.style.padding = '8px 12px'; + targetButton.style.borderRadius = 'var(--vscode-cornerRadius-small)'; + targetButton.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-button-border, transparent)'; + targetButton.style.background = 'var(--vscode-button-secondaryBackground)'; + targetButton.style.color = 'var(--vscode-button-secondaryForeground)'; + targetButton.style.cursor = 'pointer'; + targetButton.style.font = 'inherit'; + + let clickCount = 0; + disposableStore.add(dom.addDisposableListener(targetButton, dom.EventType.CLICK, () => { + clickCount++; + targetButton.textContent = `Clicked ${clickCount}`; + })); + + const overlay = disposableStore.add(new SpotlightOverlay(container)); + overlay.show(targetButton, { + title: 'Spotlight Overlay', + description: 'This callout points at a real button. The button remains clickable through the spotlight hole.', + stepIndex: 0, + stepCount: 1, + canGoBack: false, + isLastStep: true, + }, { + placement: 'above', + allowTargetInteraction: true, + }); +} From cebc749d201c4954626d2003330271fcc541a239 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:28:04 -0700 Subject: [PATCH 064/696] agentHost: revert reserved integration id change; tag Codex proxy with vscode_codex UA (#322600) * Revert "Merge pull request #321964 from microsoft/fix-agenthost-reserved-integration-id" This reverts commit b82871d0b18cd32c9d0990746db385c2c07cbd55, reversing changes made to 18ab318cd2fd140fa11d4b6a170280f496016402. * agentHost: do not pass an integration id into CAPIClient The 'code-oss' integration id is reserved and rejected by @vscode/copilot-api's CAPIClient constructor. Stop passing it in and drop the now-unnecessary getIntegrationId() test overrides; the Copilot-Integration-Id header still defaults via the library and the per-request suppressIntegrationId opt-in is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: tag Codex proxy requests with a vscode_codex user-agent Mirror the Claude proxy and oaiLanguageModelServer.ts by transforming the inbound codex user-agent (replacing the client-name portion with the vscode_codex prefix) and forwarding it to CAPI on /v1/responses requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: tag Codex agent model-list fetch with a vscode_codex user-agent The Codex agent's _refreshModels() call to ICopilotApiService.models() did not set a User-Agent, unlike the Claude agent (which sends vscode_claude_code/). Add a matching vscode_codex/ header so the model-list fetch is identifiable server-side, consistent with the Codex proxy's responses requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: simplify CopilotApiService test construction Address PR review: the anonymous CopilotApiService subclasses no longer override anything after the getIntegrationId() removal, so replace them with direct construction for readability and cleaner stack traces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/typings/copilot-api.d.ts | 1 + .../agentHost/node/claude/claudeAgent.ts | 2 +- .../node/claude/claudeProxyService.ts | 6 +- .../agentHost/node/codex/codexAgent.ts | 13 +- .../agentHost/node/codex/codexProxyService.ts | 47 +++++- .../node/shared/copilotApiService.ts | 20 +++ .../test/node/codex/codexProxyService.test.ts | 141 ++++++++++++++++++ .../node/codex/codexSessionConfigKeys.test.ts | 2 + .../node/shared/copilotApiService.test.ts | 30 ++-- 9 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts diff --git a/src/typings/copilot-api.d.ts b/src/typings/copilot-api.d.ts index 3048776fdb0e8..cf56881a70d34 100644 --- a/src/typings/copilot-api.d.ts +++ b/src/typings/copilot-api.d.ts @@ -26,6 +26,7 @@ declare module '@vscode/copilot-api' { json?: unknown; method?: 'GET' | 'POST' | 'PUT'; signal?: IAbortSignal; + suppressIntegrationId?: boolean; } export type MakeRequestOptions = Omit & { diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index d2696be4cffd9..5ca688efdb614 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -321,7 +321,7 @@ export class ClaudeAgent extends Disposable implements IAgent { } try { const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`; - const all = await this._copilotApiService.models(tokenAtStart, { headers: { 'User-Agent': userAgent } }); + const all = await this._copilotApiService.models(tokenAtStart, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true }); // Stale-write guard: if `authenticate()` rotated the token // while we were awaiting the model list, a newer refresh has // already published the right value — don't overwrite it. diff --git a/src/vs/platform/agentHost/node/claude/claudeProxyService.ts b/src/vs/platform/agentHost/node/claude/claudeProxyService.ts index 6723baf47cd2c..d4ffd3f9f9388 100644 --- a/src/vs/platform/agentHost/node/claude/claudeProxyService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeProxyService.ts @@ -253,7 +253,7 @@ export class ClaudeProxyService extends LoopbackProxyServer { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; + const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true }; let message: Anthropic.Message; try { message = await this._copilotApiService.messages(runtime.state.githubToken, body, options); @@ -427,7 +427,7 @@ export class ClaudeProxyService extends LoopbackProxyServer { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; + const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true }; let stream: AsyncGenerator; try { stream = this._copilotApiService.messages(runtime.state.githubToken, body, options); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 0d86dc41cbf53..1caa7d44c151a 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -15,6 +15,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; import { createSchema, platformSessionSchema, schemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider } from '../../common/agentService.js'; @@ -93,6 +94,14 @@ const CLIENT_INFO = { const CODEX_THINKING_LEVEL_KEY = 'thinkingLevel'; +/** + * User-agent prefix applied to the Codex agent's outbound CAPI calls (e.g. the + * model-list fetch) so the traffic is identifiable server-side. Mirrors + * `claudeAgent.ts` and the `vscode_codex` prefix used by `codexProxyService.ts` + * and `oaiLanguageModelServer.ts`. + */ +const USER_AGENT_PREFIX = 'vscode_codex'; + const CODEX_REASONING_EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high']; /** @@ -544,6 +553,7 @@ export class CodexAgent extends Disposable implements IAgent { @ICodexProxyService private readonly _codexProxyService: ICodexProxyService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentSdkDownloader private readonly _agentSdkDownloader: IAgentSdkDownloader, + @IProductService private readonly _productService: IProductService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); @@ -719,7 +729,8 @@ export class CodexAgent extends Disposable implements IAgent { private async _refreshModels(token: string): Promise { try { - const all = await this._copilotApiService.models(token); + const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`; + const all = await this._copilotApiService.models(token, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true }); if (this._githubToken !== token) { return; } diff --git a/src/vs/platform/agentHost/node/codex/codexProxyService.ts b/src/vs/platform/agentHost/node/codex/codexProxyService.ts index 642719e20691d..502737125ee27 100644 --- a/src/vs/platform/agentHost/node/codex/codexProxyService.ts +++ b/src/vs/platform/agentHost/node/codex/codexProxyService.ts @@ -72,6 +72,14 @@ type ICodexProxyRuntime = ILoopbackProxyRuntime; const PROXY_USER_FACING_NAME = 'CodexProxyService'; +/** + * User-agent prefix applied to outbound CAPI requests so the codex proxy's + * traffic is identifiable server-side. Mirrors `oaiLanguageModelServer.ts` + * in the Copilot Chat extension, which tags Codex requests with the same + * prefix. + */ +const USER_AGENT_PREFIX = 'vscode_codex'; + /** * When set to an absolute directory path, every `/v1/responses` request body * and its full upstream response stream are written to that directory as @@ -274,9 +282,11 @@ export class CodexProxyService extends LoopbackProxyServer `${k}: ${v}`).join(', '); this._logService.info(`[${PROXY_USER_FACING_NAME}] <<< CAPI response: status=${upstream.status}, contentType=${contentType}, headers=[${upstreamHeaders}]`); @@ -346,3 +356,38 @@ export class CodexProxyService extends LoopbackProxyServer { + const out: Record = {}; + const userAgent = inbound['user-agent']; + if (typeof userAgent === 'string' && userAgent.length > 0) { + out['User-Agent'] = transformUserAgent(userAgent); + } + return out; +} + +/** + * Transform an incoming user-agent string by replacing the client name portion + * (before the first `/`) with {@link USER_AGENT_PREFIX}. This mirrors the + * transform in `oaiLanguageModelServer.ts` in the Copilot Chat extension, + * ensuring all Codex requests are tagged with a consistent prefix for + * server-side identification. + * + * Examples: + * - `codex/1.2.3` → `vscode_codex/1.2.3` + * - `OpenAI/Python/1.0` → `vscode_codex/Python/1.0` + * - `unknown` → `vscode_codex/unknown` + */ +function transformUserAgent(userAgent: string): string { + const slashIndex = userAgent.indexOf('/'); + if (slashIndex === -1) { + return `${USER_AGENT_PREFIX}/${userAgent}`; + } + return `${USER_AGENT_PREFIX}${userAgent.substring(slashIndex)}`; +} diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index a405bdc995b9f..667de656e5d52 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -28,6 +28,19 @@ import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgree export interface ICopilotApiServiceRequestOptions { readonly headers?: Readonly>; readonly signal?: AbortSignal; + + /** + * Suppress the `Copilot-Integration-Id` header on this request. + * + * When unset, `@vscode/copilot-api` derives the integration id from the + * discovered Copilot SKU: a `no_auth_limited_copilot` SKU maps to + * `vscode-nl`, which the CAPI backend treats as the limited/no-auth + * integration and refuses premium models such as `claude-opus-4.7`. + * Setting this to `true` omits the header so CAPI authorizes against the + * token's real entitlement. Mirrors the Copilot Chat extension's + * `ClaudeStreamingPassThroughEndpoint.getEndpointFetchOptions()`. + */ + readonly suppressIntegrationId?: boolean; } /** @@ -522,6 +535,9 @@ export class CopilotApiService implements ICopilotApiService { ...options?.headers, 'Authorization': `Bearer ${githubToken}`, }, + // Opt-in per request — see + // `ICopilotApiServiceRequestOptions.suppressIntegrationId`. + suppressIntegrationId: options?.suppressIntegrationId, signal: options?.signal, }, { type: RequestType.Models }, @@ -566,6 +582,9 @@ export class CopilotApiService implements ICopilotApiService { 'X-Request-Id': requestId, 'OpenAI-Intent': 'conversation', }, + // Opt-in per request — see + // `ICopilotApiServiceRequestOptions.suppressIntegrationId`. + suppressIntegrationId: options?.suppressIntegrationId, body, signal: options?.signal, }, @@ -750,6 +769,7 @@ export class CopilotApiService implements ICopilotApiService { // paths already omit it). Thread a real per-turn initiator here if // that signal ever becomes available at the proxy boundary. }, + suppressIntegrationId: options?.suppressIntegrationId, body, signal: options?.signal, }, diff --git a/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts new file mode 100644 index 0000000000000..39d1bd24011cb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type * as http from 'http'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { + type ICopilotApiService, + type ICopilotApiServiceRequestOptions, +} from '../../../node/shared/copilotApiService.js'; +import { CodexProxyService } from '../../../node/codex/codexProxyService.js'; + +// #region Test fakes + +interface IResponsesCall { + githubToken: string; + body: string; + options: ICopilotApiServiceRequestOptions | undefined; +} + +class FakeCopilotApiService implements ICopilotApiService { + declare readonly _serviceBrand: undefined; + + readonly responsesCalls: IResponsesCall[] = []; + + messages(): never { + throw new Error('messages not used by Codex proxy tests'); + } + + async countTokens(): Promise { + throw new Error('countTokens not used by Codex proxy tests'); + } + + async models(): Promise { + throw new Error('models not used by Codex proxy tests'); + } + + async responses(githubToken: string, body: string, options?: ICopilotApiServiceRequestOptions): Promise { + this.responsesCalls.push({ githubToken, body, options }); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('event: response.completed\ndata: {}\n\n')); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + } + + async utilityChatCompletion(): Promise { + throw new Error('utilityChatCompletion not used by Codex proxy tests'); + } +} + +// #endregion + +// #region HTTP helpers + +let _httpModule: typeof http | undefined; +async function getHttp(): Promise { + if (!_httpModule) { + _httpModule = await import('http'); + } + return _httpModule; +} + +function postResponses(url: string, init: { headers?: Record; body?: string }): Promise<{ status: number; body: string }> { + return getHttp().then(httpMod => new Promise((resolve, reject) => { + const u = new URL(url); + const req = httpMod.request({ + hostname: u.hostname, + port: u.port, + path: u.pathname + u.search, + method: 'POST', + headers: init.headers, + }, res => { + const chunks: Buffer[] = []; + res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + res.on('error', reject); + }); + req.on('error', reject); + if (init.body !== undefined) { + req.write(init.body); + } + req.end(); + })); +} + +// #endregion + +const TOKEN = 'gh-test-token'; + +suite('CodexProxyService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + async function withProxy(fn: (handle: { baseUrl: string; nonce: string }, fake: FakeCopilotApiService) => Promise): Promise { + const fake = new FakeCopilotApiService(); + const service = new CodexProxyService(new NullLogService(), fake); + const handle = await service.start(TOKEN); + try { + await fn(handle, fake); + } finally { + handle.dispose(); + service.dispose(); + } + } + + test('forwards transformed user-agent to CAPI responses', async () => { + await withProxy(async (handle, fake) => { + await postResponses(`${handle.baseUrl}/v1/responses`, { + headers: { 'Authorization': `Bearer ${handle.nonce}`, 'User-Agent': 'codex/1.2.3' }, + body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }), + }); + assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], 'vscode_codex/1.2.3'); + }); + }); + + test('keeps the suffix when transforming a multi-segment user-agent', async () => { + await withProxy(async (handle, fake) => { + await postResponses(`${handle.baseUrl}/v1/responses`, { + headers: { 'Authorization': `Bearer ${handle.nonce}`, 'User-Agent': 'OpenAI/Python/1.0' }, + body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }), + }); + assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], 'vscode_codex/Python/1.0'); + }); + }); + + test('omits User-Agent when the inbound request has none', async () => { + await withProxy(async (handle, fake) => { + await postResponses(`${handle.baseUrl}/v1/responses`, { + headers: { 'Authorization': `Bearer ${handle.nonce}` }, + body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }), + }); + assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], undefined); + }); + }); +}); 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 14491ffb9da51..9dcd3c373f307 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { CodexSessionConfigKey, collaborationModeKind, narrowAdditionalDirectories, narrowApprovalPolicy, narrowBoolean, narrowPersonality, narrowReasoningEffort, narrowReasoningSummary, narrowSandboxMode, narrowWebSearchMode } from '../../../node/codex/codexSessionConfigKeys.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; @@ -24,6 +25,7 @@ function createAgent(disposables: Pick): CodexAgent { instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined }); instantiationService.stub(IAgentConfigurationService, { _serviceBrand: undefined }); instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined }); + instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(ILogService, new NullLogService()); return disposables.add(instantiationService.createInstance(CodexAgent)); } diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts index 710ccf6b2a261..4558221cf7ba3 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts @@ -587,18 +587,23 @@ suite('CopilotApiService', () => { assert.strictEqual(headers['OpenAI-Intent'], 'messages-proxy'); }); - test('sends a derived Copilot-Integration-Id header by default', async () => { + test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => { const { fetch: fetchFn, captured } = routingFetch( () => anthropicResponse([{ type: 'text', text: 'ok' }]), ); const service = createService(fetchFn); - // @vscode/copilot-api derives the integration id from the license / - // SKU / build state and sends it on every request. + // Default (no opt-in): @vscode/copilot-api derives and sends the header. await service.messages('gh-tok', baseRequest); - const headers = captured().init?.headers as Record; + const withHeader = captured().init?.headers as Record; + + // Opt-in: the header is omitted entirely so CAPI authorizes against + // the token's real entitlement instead of the derived integration id. + await service.messages('gh-tok', baseRequest, { suppressIntegrationId: true }); + const suppressed = captured().init?.headers as Record; - assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present'); + assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default'); + assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in'); }); }); @@ -1585,16 +1590,21 @@ suite('CopilotApiService', () => { assert.strictEqual(capturedHeaders?.['Authorization'], 'Bearer gh-tok'); }); - test('sends a derived Copilot-Integration-Id header by default', async () => { + test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => { const { fetch: fetchFn, captured } = routingFetch(() => modelsResponse([])); const service = createService(fetchFn); - // @vscode/copilot-api derives the integration id from the license / - // SKU / build state and sends it on every request. + // Default (no opt-in): @vscode/copilot-api derives and sends the header. await service.models('gh-tok'); - const headers = captured().init?.headers as Record; + const withHeader = captured().init?.headers as Record; + + // Opt-in: the header is omitted entirely so CAPI authorizes against + // the token's real entitlement instead of the derived integration id. + await service.models('gh-tok', { suppressIntegrationId: true }); + const suppressed = captured().init?.headers as Record; - assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present'); + assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default'); + assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in'); }); }); From 7dacbbb4f33d9f9ed52f8629b40326b753bdbbaf Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 23 Jun 2026 17:30:37 -0400 Subject: [PATCH 065/696] Remove voice mode aux window (#322608) --- .../browser/agentsVoice.contribution.ts | 77 +------------------ .../browser/agentsVoiceWindowService.ts | 24 +----- .../widgetHosts/viewPane/chatViewPane.ts | 12 --- 3 files changed, 5 insertions(+), 108 deletions(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index fc1a4db1589bf..3042d60c5c50f 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -3,9 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Register the Agents Voice window service singleton -import './agentsVoiceWindowService.js'; - // Register voice client services import '../../chat/browser/voiceClient/micCaptureService.js'; import '../../chat/browser/voiceClient/ttsPlaybackService.js'; @@ -25,7 +22,6 @@ import { autorun } from '../../../../base/common/observable.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import * as nls from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -34,7 +30,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IAgentsVoiceWindowService, AgentsVoiceStorageKeys } from '../common/agentsVoice.js'; +import { AgentsVoiceStorageKeys } from '../common/agentsVoice.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -50,7 +46,6 @@ import { IChatWidgetService } from '../../chat/browser/chat.js'; // --- Context Keys --- -const AGENTS_VOICE_WINDOW_VISIBLE = new RawContextKey('agentsVoiceWindowVisible', false); export const AGENTS_VOICE_WIDGET_FOCUSED = new RawContextKey('agentsVoiceWidgetFocused', false); const AGENTS_VOICE_CONNECTED = new RawContextKey('agentsVoiceConnected', false); const AGENTS_VOICE_CONNECTING = new RawContextKey('agentsVoiceConnecting', false); @@ -61,27 +56,6 @@ const AGENTS_VOICE_INITIATED_HERE = new RawContextKey('agentsVoiceIniti // --- Context Key Binding --- -class AgentsVoiceContextKeyContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.agentsVoiceContextKey'; - - constructor( - @IAgentsVoiceWindowService private readonly agentsVoiceWindowService: IAgentsVoiceWindowService, - @IContextKeyService contextKeyService: IContextKeyService, - ) { - super(); - - const windowKey = AGENTS_VOICE_WINDOW_VISIBLE.bindTo(contextKeyService); - windowKey.set(this.agentsVoiceWindowService.isOpen); - - this._register(this.agentsVoiceWindowService.onDidChangeOpen(isOpen => { - windowKey.set(isOpen); - })); - } -} - -registerWorkbenchContribution2(AgentsVoiceContextKeyContribution.ID, AgentsVoiceContextKeyContribution, WorkbenchPhase.AfterRestored); - // Separate contribution for voice connected state — runs later to avoid // forcing IVoiceSessionController instantiation too early. class AgentsVoiceConnectedKeyContribution extends Disposable implements IWorkbenchContribution { @@ -154,48 +128,6 @@ class AgentsVoiceTelemetryContribution extends Disposable implements IWorkbenchC registerWorkbenchContribution2(AgentsVoiceTelemetryContribution.ID, AgentsVoiceTelemetryContribution, WorkbenchPhase.AfterRestored); -// --- Toggle Command + Menu Item --- - -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'agentsVoice.toggleWindow', - title: nls.localize2('toggleAgentsVoiceWindow', "Voice Mode"), - icon: Codicon.openInWindow, - menu: [{ - id: MenuId.MenubarViewMenu, - group: '5_copilot', - order: 1, - when: ContextKeyExpr.equals('config.agents.voice.enabled', true), - }, { - id: MenuId.ChatExecute, - when: ContextKeyExpr.and( - ContextKeyExpr.equals('config.agents.voice.enabled', true), - AGENTS_VOICE_CONNECTED.isEqualTo(true), - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), - AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), - ), - group: 'navigation', - order: 6 - }], - toggled: AGENTS_VOICE_WINDOW_VISIBLE.isEqualTo(true), - }); - } - async run(accessor: ServicesAccessor): Promise { - const service = accessor.get(IAgentsVoiceWindowService); - await service.toggleWindow(); - } -}); - -// Internal command: open the floating window without toggling (used by voice -// controller to surface responses for non-visible sessions). -CommandsRegistry.registerCommand('_agentsVoice.openWindow', async (accessor) => { - const service = accessor.get(IAgentsVoiceWindowService); - if (!service.isOpen) { - await service.openWindow(); - } -}); - // --- Voice mode button in Chat toolbar --- // Shows the voice mode icon in both idle and active states. // Click to connect if disconnected, or toggle PTT if connected. @@ -484,13 +416,6 @@ configurationRegistry.registerConfiguration({ restricted: true, included: false, }, - 'agents.voice.alwaysOnTop': { - type: 'boolean', - description: nls.localize('agents.voice.alwaysOnTop', "Keep the Voice Mode window always on top of other windows."), - default: true, - scope: ConfigurationScope.APPLICATION, - included: false, - }, 'agents.voice.backendUrl': { type: 'string', description: nls.localize('agents.voice.backendUrl', "Voice backend WebSocket URL. Leave empty to use the default hosted backend. Set to e.g. `ws://localhost:8000/api/v1/realtime/voice` to point at a backend running on your machine."), diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index d48ba7fb023c0..5a7ee77fb0bed 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -55,14 +55,6 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice * Calls setWindowAlwaysOnTop via a registered command (Electron only). * Avoids importing INativeHostService in the browser layer. */ - private async trySetWindowAlwaysOnTop(alwaysOnTop: boolean, targetWindowId: number): Promise { - try { - await this.commandService.executeCommand('_agentsVoice.setWindowAlwaysOnTop', alwaysOnTop, targetWindowId); - } catch { - // Command not registered (e.g. web) — ignore - } - } - constructor( @IAuxiliaryWindowService private readonly auxiliaryWindowService: IAuxiliaryWindowService, @IStorageService private readonly storageService: IStorageService, @@ -101,17 +93,10 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice mainWindow.addEventListener('beforeunload', onBeforeUnload); this._register({ dispose: () => mainWindow.removeEventListener('beforeunload', onBeforeUnload) }); - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('agents.voice.alwaysOnTop') && this._window) { - const alwaysOnTop = this.configurationService.getValue('agents.voice.alwaysOnTop') ?? true; - this.trySetWindowAlwaysOnTop(alwaysOnTop, this._window.window.vscodeWindowId); - } - })); - const wasOpen = this.storageService.getBoolean(AgentsVoiceStorageKeys.WindowOpen, StorageScope.WORKSPACE, false); - if (wasOpen && this.configurationService.getValue('agents.voice.enabled')) { - const reopenTimeout = setTimeout(() => this.openWindow(), 1000); - this._register({ dispose: () => clearTimeout(reopenTimeout) }); + if (wasOpen) { + // Clear the stored state so it doesn't try to reopen in the future + this.storageService.store(AgentsVoiceStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); } } @@ -121,11 +106,10 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice } const bounds = this.loadBounds(); - const alwaysOnTop = this.configurationService.getValue('agents.voice.alwaysOnTop') ?? true; const auxiliaryWindow = await this.auxiliaryWindowService.open({ bounds, - alwaysOnTop, + alwaysOnTop: true, frameless: true, transparent: false, disableFullscreen: true, 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 73dc2141ea9f5..b61c691c4d35a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -72,7 +72,6 @@ import { IHostService } from '../../../../../services/host/browser/host.js'; import { IMicCaptureService } from '../../voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; -import { IAgentsVoiceWindowService } from '../../../../agentsVoice/common/agentsVoice.js'; import { IAgentTitleBarStatusService } from '../../agentSessions/experiments/agentTitleBarStatusService.js'; import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; @@ -140,7 +139,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @IMicCaptureService private readonly micCaptureService: IMicCaptureService, @ITtsPlaybackService private readonly ttsPlaybackService: ITtsPlaybackService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, - @IAgentsVoiceWindowService private readonly agentsVoiceWindowService: IAgentsVoiceWindowService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IAgentTitleBarStatusService _agentTitleBarStatusService: IAgentTitleBarStatusService, @IVoicePlaybackService _voicePlaybackService: IVoicePlaybackService, @@ -563,16 +561,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return; } - // If aux window is open and voice is targeting a different session, - // don't show transcript in the chat input — it's shown in aux window instead. - const targetSession = this.voiceSessionController.targetSession.read(reader); - const currentSession = this._widget?.viewModel?.sessionResource; - if (this.agentsVoiceWindowService.isOpen && targetSession && currentSession && targetSession.toString() !== currentSession.toString()) { - transcriptOverlayNode.style.display = 'none'; - transcriptOverlayNode.classList.remove('has-transcript'); - return; - } - // Show hint when connected but no transcript yet if (visible.length === 0 || !showTranscript) { const autoSendDelay = this.configurationService.getValue('agents.voice.autoSendDelay') ?? 500; From 5cfeac37d84427fdf90c882a1becaf5d008a5810 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 23 Jun 2026 17:46:40 -0400 Subject: [PATCH 066/696] Voice mode: cleaner narration, Copilot-session responses, and button position (#322613) --- .../browser/agentsVoice.contribution.ts | 6 +- .../voiceClient/voiceSessionController.ts | 91 +++++++++++++++++-- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 3042d60c5c50f..7585d6dbffd9a 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -152,7 +152,7 @@ registerAction2(class extends Action2 { AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), ), group: 'navigation', - order: 4 + order: -10 } }); } @@ -178,7 +178,7 @@ registerAction2(class extends Action2 { AGENTS_VOICE_CONNECTING.negate(), ), group: 'navigation', - order: 4 + order: -10 }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, @@ -227,7 +227,7 @@ registerAction2(class extends Action2 { AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), ), group: 'navigation', - order: 4 + order: -10 }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 85551e7aed72c..63c607aa8d607 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -218,6 +218,17 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC /** Model refs eagerly loaded for sessions awaiting input (no UI focus needed). */ private readonly _eagerModelRefs = new Map(); + /** Sessions with an in-flight eager model load, to dedupe concurrent loads. */ + private readonly _eagerModelLoading = new Set(); + + /** + * Sessions whose ``idle`` transition is being deferred until their chat + * model loads, so the narration can include ``last_response_summary``. + * While a session id is in this set we suppress emitting a premature, + * summary-less ``idle`` to the backend (see _buildSessionContext). + */ + private readonly _pendingIdleNarration = new Set(); + // --- Telemetry tracking --- private _telemetrySessionIndex = 0; private _telemetrySessionStart: number | undefined; @@ -663,9 +674,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Reactive session context autorun const sessionChangeListener = this.agentSessionsService.model.onDidChangeSessions(() => { - this._sendContext(); - // Also check state changes for sessions without a loaded model + // Check state changes first so any deferred idle narration is + // registered (and premature idle suppressed) before we flush + // the session context to the backend. this._checkSessionStateChanges(); + this._sendContext(); }); const autorunDisposable = autorun(reader => { const agentSessions = this.agentSessionsService.model.sessions.filter(s => !s.isArchived()); @@ -677,6 +690,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // --- Helper: subscribe to a chat model's observables and detect state changes --- const processModel = (model: IChatModel, resource: URI, label: string) => { const sessionId = resource.toString(); + // The model is now resident so its idle transition will carry a + // proper summary; drop any pending deferral/suppression. + this._pendingIdleNarration.delete(sessionId); const lastReq = model.lastRequestObs.read(reader); if (lastReq?.response) { lastReq.response.isIncomplete.read(reader); @@ -749,6 +765,18 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const prev = this._prevSessionStates.get(sessionId); const isStateTransition = prev !== undefined && prev.state !== currentState && currentState !== 'unknown'; + + // Remote/Copilot sessions don't keep their model resident, so a + // coarse ``idle`` transition would carry no last_response_summary + // and the backend would narrate an empty completion. Defer the + // transition: eagerly load the model and let the autorun re-fire + // with the summary once it resolves. Do not record the idle state + // yet so the transition is still detected after the model loads. + if (isStateTransition && currentState === 'idle') { + this._deferIdleNarrationUntilModelLoaded(s.resource); + continue; + } + if (isStateTransition) { const cancelExpiry = this._userCancelledSessions.get(sessionId); if (cancelExpiry) { @@ -1070,6 +1098,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._confirmationFlushWatchdogs.clear(); for (const ref of this._eagerModelRefs.values()) { ref.dispose(); } this._eagerModelRefs.clear(); + this._eagerModelLoading.clear(); + this._pendingIdleNarration.clear(); this._userLogin = undefined; this._lastPersistedTurnId = undefined; this._pendingPriorTimeline = []; @@ -1983,6 +2013,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC currentState = info.state; detail = info.detail; lastResponseSummary = info.last_response_summary; + // Model is resident now; drop any pending idle deferral/suppression. + this._pendingIdleNarration.delete(sessionId); } else { currentState = s.status === AgentSessionStatus.InProgress ? 'thinking' : s.status === AgentSessionStatus.NeedsInput ? 'waiting_for_confirmation' @@ -1996,6 +2028,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const prev = this._prevSessionStates.get(sessionId); const isStateChange = prev !== undefined && prev.state !== currentState && currentState !== 'unknown'; const isDetailChange = !isStateChange && prev !== undefined && currentState === 'waiting_for_confirmation' && (detail ?? '') !== prev.detail; + + // Defer summary-less idle transitions for remote/Copilot sessions until + // their model loads (see _deferIdleNarrationUntilModelLoaded). + if (!model && currentState === 'idle' && isStateChange) { + this._deferIdleNarrationUntilModelLoaded(s.resource); + continue; + } + if (isStateChange || isDetailChange) { const cancelExpiry = this._userCancelledSessions.get(sessionId); if (cancelExpiry) { @@ -2080,12 +2120,22 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const model = this.chatService.getSession(s.resource); const isActive = s.resource.toString() === targetSessionId; if (!model) { - const fallbackState = s.status === AgentSessionStatus.InProgress ? 'thinking' + const sessionIdStr = s.resource.toString(); + let fallbackState = s.status === AgentSessionStatus.InProgress ? 'thinking' : s.status === AgentSessionStatus.NeedsInput ? 'waiting_for_confirmation' : s.status === AgentSessionStatus.Completed ? 'idle' : 'unknown'; + // If this idle transition is deferred until the model loads, keep + // reporting the prior state so the backend doesn't narrate a + // premature, summary-less completion. See _pendingIdleNarration. + if (fallbackState === 'idle' && this._pendingIdleNarration.has(sessionIdStr)) { + const prev = this._prevSessionStates.get(sessionIdStr); + if (prev?.state) { + fallbackState = prev.state; + } + } return { - id: s.resource.toString(), + id: sessionIdStr, is_active: isActive, agent_state: fallbackState, }; @@ -2149,21 +2199,46 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC */ private _ensureModelLoaded(resource: URI): void { const key = resource.toString(); - if (this._eagerModelRefs.has(key) || this.chatService.getSession(resource)) { + // Skip if already loaded, resident in the UI, or a load is in flight. + // The in-flight guard prevents repeated onDidChangeSessions/autorun + // cycles from starting concurrent loads whose refs would overwrite each + // other in _eagerModelRefs and leak the prior ref. + if (this._eagerModelRefs.has(key) || this._eagerModelLoading.has(key) || this.chatService.getSession(resource)) { return; } this.logService.info(`[voice] eagerly loading model for session ${key.slice(-32)}`); + this._eagerModelLoading.add(key); const cts = new CancellationTokenSource(); this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, cts.token, 'VoiceSessionController#eagerLoad').then(ref => { + this._eagerModelLoading.delete(key); if (ref) { - if (!this._isConnected.get()) { + const existing = this._eagerModelRefs.get(key); + if (!this._isConnected.get() || existing) { ref.dispose(); + if (!this._isConnected.get()) { + this._pendingIdleNarration.delete(key); + } } else { this._eagerModelRefs.set(key, ref); } + } else { + // Load failed; stop suppressing the coarse idle for this session. + this._pendingIdleNarration.delete(key); } cts.dispose(); - }, () => { cts.dispose(); }); + }, () => { this._eagerModelLoading.delete(key); this._pendingIdleNarration.delete(key); cts.dispose(); }); + } + + /** + * Defer narrating a session's ``idle`` transition until its chat model is + * resident, so the narration can include ``last_response_summary``. Remote/ + * Copilot sessions don't keep their model loaded, so without this the + * backend would only ever see a summary-less completion. Eagerly loads the + * model; once it resolves the autorun re-fires and narrates with the summary. + */ + private _deferIdleNarrationUntilModelLoaded(resource: URI): void { + this._pendingIdleNarration.add(resource.toString()); + this._ensureModelLoaded(resource); } private _getAgentStateInfo(model: IChatModel | undefined | null): { state: string; detail?: string; last_response_summary?: string } { @@ -2265,7 +2340,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return { state: 'thinking' }; } - const responseText = lastRequest?.response?.response.toString() ?? ''; + const responseText = lastRequest?.response?.response.getMarkdown().trim() ?? ''; return { state: 'idle', ...(responseText ? { last_response_summary: responseText } : {}) }; } From aa0015f7d14155b35440ed273b6af88c417bd79e Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 24 Jun 2026 00:16:38 +0200 Subject: [PATCH 067/696] PR hover --- src/vs/sessions/LAYOUT.md | 2 +- .../browser/parts/media/chatCompositeBar.css | 1 + .../github/browser/media/pullRequestHover.css | 84 +++++++++++++++++++ .../github/browser/pullRequestActions.ts | 58 +++++++++++-- .../github/browser/pullRequestHover.ts | 76 +++++++++++++++++ .../sessions/githubFixtureUtils.ts | 58 +++++++++++++ .../sessions/openPullRequest.fixture.ts | 78 ++++++++++++++++- .../sessions/sessionHeader.fixture.ts | 23 +++++ 8 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 src/vs/sessions/contrib/github/browser/media/pullRequestHover.css create mode 100644 src/vs/sessions/contrib/github/browser/pullRequestHover.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.ts diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index f4a541acb1ded..aad2e42f5c8e0 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -124,7 +124,7 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (workspace · pull request · diff stats), and the session toolbars (Run, Open in VS Code, New Chat). The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; the changes view contributes the diff stats as a clickable menu item (gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's changes, with its custom action view item registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The GitHub contribution similarly contributes a `#` pull request pill (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with its custom action view item registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. +- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (workspace · pull request · diff stats), and the session toolbars (Run, Open in VS Code, New Chat). The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; the changes view contributes the diff stats as a clickable menu item (gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's changes, with its custom action view item registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The GitHub contribution similarly contributes a `#` pull request pill (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with its custom action view item registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. - A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility is **sticky per opened session**: it appears once the session has more than one chat, or its single (default) chat carries a title that differs from the session title (so both independent titles stay visible), and from then on it stays shown for that session's lifetime — it is never hidden again when chats are later removed or renamed, keeping the experience consistent. A single chat that still inherits the session title (and never diverged) keeps the strip hidden. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index c300a916864bc..337da5366ccba 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -220,6 +220,7 @@ .chat-composite-bar-meta-diff .action-label:hover, .chat-composite-bar-meta-pr .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); color: inherit; text-decoration: none; } diff --git a/src/vs/sessions/contrib/github/browser/media/pullRequestHover.css b/src/vs/sessions/contrib/github/browser/media/pullRequestHover.css new file mode 100644 index 0000000000000..7fe3d3146f1e2 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/media/pullRequestHover.css @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-pr-hover { + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 520px; + max-width: calc(100vw - var(--vscode-spacing-size320)); + color: var(--vscode-editorHoverWidget-foreground); +} + +.sessions-pr-hover-header { + display: flex; + align-items: baseline; + gap: var(--vscode-spacing-size40); + min-width: 0; + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160) 0; + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.4; +} + +.sessions-pr-hover-repository { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.sessions-pr-hover-date { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); +} + +.sessions-pr-hover-title { + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size160) var(--vscode-spacing-size120); + font-size: var(--vscode-agents-fontSize-heading2, 18px); + font-weight: var(--vscode-agents-fontWeight-semiBold, 600); + line-height: 1.25; +} + +.sessions-pr-hover-description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160); + border-top: var(--vscode-strokeThickness) solid var(--vscode-editorHoverWidget-border); + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.sessions-pr-hover-branches { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + min-width: 0; + padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size120); +} + +.sessions-pr-hover-branch { + min-width: 0; + max-width: 200px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-circle); + background-color: var(--vscode-textCodeBlock-background); + border: var(--vscode-strokeThickness) solid var(--vscode-editorHoverWidget-border); + color: var(--vscode-textLink-foreground); + font-family: var(--monaco-monospace-font); + font-size: var(--vscode-agents-fontSize-label1); + line-height: 1.4; +} + +.sessions-pr-hover-branch-arrow { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); +} diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts index 49a85d47732cb..4b3f64cb3a4d5 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -5,10 +5,11 @@ import { $, reset } from '../../../../base/browser/dom.js'; import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IManagedHoverContent } from '../../../../base/browser/ui/hover/hover.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; @@ -21,6 +22,9 @@ import { SessionHasPullRequestContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { IGitHubPullRequest } from '../common/types.js'; +import { IGitHubService } from './githubService.js'; +import { createPullRequestHoverElement } from './pullRequestHover.js'; // --- Open Pull Request action @@ -79,23 +83,41 @@ function getPullRequestUri(session: IActiveSession | undefined): URI | undefined */ export class OpenPullRequestActionViewItem extends ActionViewItem { - private readonly _pullRequestNumberObs: IObservable; + private readonly _pullRequestIdentityObs: IObservable<{ readonly owner: string; readonly repo: string; readonly number: number } | undefined>; + private readonly _pullRequestObs: IObservable; constructor( action: MenuItemAction, options: IActionViewItemOptions, @ISessionContext sessionContext: ISessionContext, + @IGitHubService private readonly _gitHubService: IGitHubService, + @IOpenerService private readonly _openerService: IOpenerService, ) { super(undefined, action, { ...options, icon: false, label: true }); - this._pullRequestNumberObs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + this._pullRequestIdentityObs = derivedOpts<{ readonly owner: string; readonly repo: string; readonly number: number } | undefined>({ owner: this, equalsFn: structuralEquals }, reader => { const session = sessionContext.session.read(reader); const workspace = session?.workspace.read(reader); - return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest?.number; + const gitHubInfo = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return undefined; + } + return { owner: gitHubInfo.owner, repo: gitHubInfo.repo, number: gitHubInfo.pullRequest.number }; + }); + + this._pullRequestObs = derived(reader => { + const identity = this._pullRequestIdentityObs.read(reader); + if (!identity) { + return undefined; + } + + const reference = reader.store.add(this._gitHubService.createPullRequestModelReference(identity.owner, identity.repo, identity.number)); + return reference.object.pullRequest.read(reader); }); this._register(autorun(reader => { - this._pullRequestNumberObs.read(reader); + this._pullRequestIdentityObs.read(reader); + this._pullRequestObs.read(reader); this.updateLabel(); this.updateTooltip(); })); @@ -110,19 +132,41 @@ export class OpenPullRequestActionViewItem extends ActionViewItem { if (!this.label) { return; } - const number = this._pullRequestNumberObs.get(); + const number = this._pullRequestIdentityObs.get()?.number; reset( this.label, $('span.chat-composite-bar-meta-pr-label', undefined, number !== undefined ? `#${number}` : ''), ); } + protected override getHoverContents(): IManagedHoverContent | undefined { + const identity = this._pullRequestIdentityObs.get(); + if (!identity) { + return this.getTooltip(); + } + + return { + element: () => createPullRequestHoverElement({ + owner: identity.owner, + repo: identity.repo, + number: identity.number, + repositoryHref: this._getRepositoryUri(identity).toString(true), + pullRequest: this._pullRequestObs.get(), + onDidClickRepository: () => this._openerService.open(this._getRepositoryUri(identity), { openExternal: true }), + }), + }; + } + protected override getTooltip(): string { - const number = this._pullRequestNumberObs.get(); + const number = this._pullRequestIdentityObs.get()?.number; return number !== undefined ? localize('agentSessions.openPullRequest.tooltipWithNumber', "Open Pull Request #{0}", number) : localize('agentSessions.openPullRequest.tooltip', "Open Pull Request"); } + + private _getRepositoryUri(identity: { readonly owner: string; readonly repo: string }): URI { + return URI.parse(`https://github.com/${identity.owner}/${identity.repo}`); + } } /** diff --git a/src/vs/sessions/contrib/github/browser/pullRequestHover.ts b/src/vs/sessions/contrib/github/browser/pullRequestHover.ts new file mode 100644 index 0000000000000..706c28c6254c8 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/pullRequestHover.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/pullRequestHover.css'; + +import { $, append } from '../../../../base/browser/dom.js'; +import { safeIntl } from '../../../../base/common/date.js'; +import { localize } from '../../../../nls.js'; +import { IGitHubPullRequest } from '../common/types.js'; + +const pullRequestDateFormatter = safeIntl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }); + +export interface IPullRequestHoverData { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly repositoryHref: string; + readonly pullRequest: IGitHubPullRequest | undefined; + readonly onDidClickRepository?: () => void; +} + +export function createPullRequestHoverElement(data: IPullRequestHoverData): HTMLElement { + const hoverElement = $('.sessions-pr-hover'); + + const header = append(hoverElement, $('.sessions-pr-hover-header')); + const repositoryLink = document.createElement('a'); + repositoryLink.className = 'sessions-pr-hover-repository'; + append(header, repositoryLink); + repositoryLink.href = data.repositoryHref; + repositoryLink.textContent = `${data.owner}/${data.repo}`; + repositoryLink.title = repositoryLink.textContent; + if (data.onDidClickRepository) { + repositoryLink.onclick = event => { + event.preventDefault(); + event.stopPropagation(); + data.onDidClickRepository?.(); + }; + } + + const date = formatPullRequestDate(data.pullRequest?.createdAt); + if (date) { + append(header, $('span.sessions-pr-hover-date', undefined, localize('agentSessions.pullRequestHover.onDate', "on {0}", date))); + } + + append(hoverElement, $('.sessions-pr-hover-title', undefined, data.pullRequest?.title || localize('agentSessions.pullRequestHover.titleFallback', "Pull Request #{0}", data.number))); + + const body = data.pullRequest?.body.trim() || localize('agentSessions.pullRequestHover.bodyFallback', "No description provided."); + append(hoverElement, $('.sessions-pr-hover-description', undefined, body)); + + const branchRow = append(hoverElement, $('.sessions-pr-hover-branches')); + appendBranchPill(branchRow, data.pullRequest?.baseRef || localize('agentSessions.pullRequestHover.baseFallback', "target")); + append(branchRow, $('span.sessions-pr-hover-branch-arrow', undefined, '\u2190')); + appendBranchPill(branchRow, data.pullRequest?.headRef || localize('agentSessions.pullRequestHover.headFallback', "source")); + + return hoverElement; +} + +function appendBranchPill(container: HTMLElement, label: string): void { + const branch = append(container, $('span.sessions-pr-hover-branch', undefined, label)); + branch.title = label; +} + +function formatPullRequestDate(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return undefined; + } + + return pullRequestDateFormatter.value.format(date); +} diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.ts new file mode 100644 index 0000000000000..77b10c866e7ce --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.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 { ImmortalReference, IReference } from '../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubPRFetcher } from '../../../../../sessions/contrib/github/browser/fetchers/githubPRFetcher.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestCIModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestCIModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestReviewThreadsModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubPullRequest } from '../../../../../sessions/contrib/github/common/types.js'; + +interface IFixturePullRequestEntry { + readonly owner: string; + readonly repo: string; + readonly pullRequest: IGitHubPullRequest; +} + +class FixtureGitHubPRFetcher extends mock() { } + +class FixtureGitHubPullRequestModel extends GitHubPullRequestModel { + + override readonly pullRequest: IObservable; + + constructor(owner: string, repo: string, prNumber: number, pullRequest: IGitHubPullRequest | undefined) { + super(owner, repo, prNumber, new FixtureGitHubPRFetcher(), new NullLogService()); + this.pullRequest = constObservable(pullRequest); + } +} + +export function createFixtureGitHubService(entries: readonly IFixturePullRequestEntry[]): IGitHubService { + return new class extends mock() { + override readonly activeSessionPullRequestObs = constObservable(undefined); + override readonly activeSessionPullRequestCIObs = constObservable(undefined); + override readonly activeSessionPullRequestReviewThreadsObs = constObservable(undefined); + + private readonly _pullRequests = new Map(entries.map(entry => [toPullRequestKey(entry.owner, entry.repo, entry.pullRequest.number), entry.pullRequest])); + + override createPullRequestModelReference(owner: string, repo: string, prNumber: number): IReference { + const pullRequest = this._pullRequests.get(toPullRequestKey(owner, repo, prNumber)); + return new ImmortalReference(new FixtureGitHubPullRequestModel(owner, repo, prNumber, pullRequest)); + } + }(); +} + +function toPullRequestKey(owner: string, repo: string, prNumber: number): string { + return `${owner}/${repo}/${prNumber}`; +} diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts index 13a6443671d2d..79bbee22961b2 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts @@ -16,11 +16,20 @@ import { IActiveSession } from '../../../../../sessions/services/sessions/common // eslint-disable-next-line local/code-import-patterns import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; // eslint-disable-next-line local/code-import-patterns +import { IGitHubPullRequest, GitHubPullRequestState } from '../../../../../sessions/contrib/github/common/types.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; +// eslint-disable-next-line local/code-import-patterns +import { createPullRequestHoverElement } from '../../../../../sessions/contrib/github/browser/pullRequestHover.js'; +// eslint-disable-next-line local/code-import-patterns import { OpenPullRequestActionViewItem } from '../../../../../sessions/contrib/github/browser/pullRequestActions.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { createFixtureGitHubService } from './githubFixtureUtils.js'; // eslint-disable-next-line local/code-import-patterns import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; +import '../../../../../base/browser/ui/hover/hoverWidget.css'; +import '../../../../../platform/hover/browser/hover.css'; // ============================================================================ // Mock helpers @@ -66,7 +75,7 @@ function createMockSession(pullRequest: IGitHubInfo['pullRequest']): IActiveSess // Render helper // ============================================================================ -function renderPullRequestPill(ctx: ComponentFixtureContext, pullRequest: IGitHubInfo['pullRequest']): void { +function renderPullRequestPill(ctx: ComponentFixtureContext, pullRequest: IGitHubInfo['pullRequest'], pullRequestDetails: IGitHubPullRequest): void { const { container, disposableStore } = ctx; const session = observableValue('session', createMockSession(pullRequest)); @@ -75,6 +84,7 @@ function renderPullRequestPill(ctx: ComponentFixtureContext, pullRequest: IGitHu colorTheme: ctx.theme, additionalServices: (reg) => { reg.defineInstance(ISessionContext, new SessionContext(session)); + reg.defineInstance(IGitHubService, createFixtureGitHubService([{ owner: 'microsoft', repo: 'vscode', pullRequest: pullRequestDetails }])); }, }); @@ -102,6 +112,36 @@ function renderPullRequestPill(ctx: ComponentFixtureContext, pullRequest: IGitHu container.style.backgroundColor = 'var(--vscode-sideBar-background)'; } +function renderPullRequestHover(ctx: ComponentFixtureContext, pullRequest: IGitHubPullRequest): void { + const { container } = ctx; + + container.style.padding = '24px'; + container.style.width = '580px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const hover = document.createElement('div'); + hover.classList.add('monaco-hover', 'workbench-hover'); + hover.style.position = 'static'; + hover.style.display = 'inline-block'; + + const row = document.createElement('div'); + row.classList.add('hover-row', 'markdown-hover'); + hover.appendChild(row); + + const contents = document.createElement('div'); + contents.classList.add('hover-contents', 'html-hover-contents'); + contents.appendChild(createPullRequestHoverElement({ + owner: 'microsoft', + repo: 'vscode', + number: pullRequest.number, + repositoryHref: 'https://github.com/microsoft/vscode', + pullRequest, + })); + row.appendChild(contents); + + container.appendChild(hover); +} + const openPr: IGitHubInfo['pullRequest'] = { number: 12345, uri: URI.parse('https://github.com/microsoft/vscode/pull/12345'), @@ -114,6 +154,34 @@ const draftPr: IGitHubInfo['pullRequest'] = { icon: { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }, }; +const openPullRequestDetails: IGitHubPullRequest = { + number: openPr.number, + title: 'fix: suppress expected EPIPE error on graceful client disconnect', + body: 'Problem On every graceful client disconnect, the server logs an [error] Error: Unexpected EPIPE. This makes the expected disconnect path look like a real server failure and makes log scanning noisy for people investigating connection issues.', + state: GitHubPullRequestState.Open, + author: { login: 'hariharjeevan', avatarUrl: '' }, + headRef: 'fix-suppress-expected-epipe-error', + headSha: 'abc123', + baseRef: 'main', + isDraft: false, + createdAt: '2026-06-22T10:00:00Z', + updatedAt: '2026-06-22T12:00:00Z', + mergedAt: undefined, + mergeable: true, + mergeableState: 'clean', +}; + +const draftPullRequestDetails: IGitHubPullRequest = { + ...openPullRequestDetails, + number: draftPr.number, + title: 'draft: add session PR hover content', + body: 'Adds the first pass of the session header pull request hover with intentionally long branch names so truncation can be reviewed in component fixtures.', + state: GitHubPullRequestState.Open, + headRef: 'users/alex/very-long-session-pr-hover-fixture-branch-name', + isDraft: true, + createdAt: '2026-06-05T10:00:00Z', +}; + // ============================================================================ // Fixtures // ============================================================================ @@ -121,10 +189,14 @@ const draftPr: IGitHubInfo['pullRequest'] = { export default defineThemedFixtureGroup({ path: 'sessions/' }, { OpenPullRequest_Open: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, openPr), + render: (ctx) => renderPullRequestPill(ctx, openPr, openPullRequestDetails), }), OpenPullRequest_Draft: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, draftPr), + render: (ctx) => renderPullRequestPill(ctx, draftPr, draftPullRequestDetails), + }), + + OpenPullRequest_Hover: defineComponentFixture({ + render: (ctx) => renderPullRequestHover(ctx, openPullRequestDetails), }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts index bc24841fb7e3e..5820ac9d4bf77 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts @@ -27,11 +27,16 @@ import { Menus } from '../../../../../sessions/browser/menus.js'; // eslint-disable-next-line local/code-import-patterns import { SessionHeader } from '../../../../../sessions/browser/parts/sessionHeader.js'; // eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestState, IGitHubPullRequest } from '../../../../../sessions/contrib/github/common/types.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; +// eslint-disable-next-line local/code-import-patterns import { OpenPullRequestActionViewItem } from '../../../../../sessions/contrib/github/browser/pullRequestActions.js'; // eslint-disable-next-line local/code-import-patterns import { ViewAllChangesActionViewItem } from '../../../../../sessions/contrib/changes/browser/changesActions.js'; import { FixtureMenuService } from '../chat/chatFixtureUtils.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; +import { createFixtureGitHubService } from './githubFixtureUtils.js'; // eslint-disable-next-line local/code-import-patterns import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; @@ -189,6 +194,7 @@ function renderHeader(ctx: ComponentFixtureContext, session: IActiveSession): vo reg.define(IMenuService, FixtureMenuService); reg.defineInstance(IActionViewItemService, actionViewItemService); reg.defineInstance(ISessionContext, new SessionContext(constObservable(session))); + reg.defineInstance(IGitHubService, createFixtureGitHubService([{ owner: 'microsoft', repo: 'vscode', pullRequest: openPullRequestDetails }])); reg.defineInstance(ISessionsListModelService, createMockListModelService()); reg.defineInstance(ISessionsManagementService, new class extends mock() { override readonly onDidChangeSessions = Event.None; @@ -238,6 +244,23 @@ const openPr: IGitHubInfo['pullRequest'] = { icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }, }; +const openPullRequestDetails: IGitHubPullRequest = { + number: openPr.number, + title: 'fix: suppress expected EPIPE error on graceful client disconnect', + body: 'Problem On every graceful client disconnect, the server logs an [error] Error: Unexpected EPIPE. This makes the expected disconnect path look like a real server failure and makes log scanning noisy for people investigating connection issues.', + state: GitHubPullRequestState.Open, + author: { login: 'hariharjeevan', avatarUrl: '' }, + headRef: 'fix-suppress-expected-epipe-error', + headSha: 'abc123', + baseRef: 'main', + isDraft: false, + createdAt: '2026-06-22T10:00:00Z', + updatedAt: '2026-06-22T12:00:00Z', + mergedAt: undefined, + mergeable: true, + mergeableState: 'clean', +}; + function createMockChange(insertions: number, deletions: number): ISessionFileChange { return { modifiedUri: URI.file(`/repo/file-${Math.random().toString(36).slice(2)}.ts`), From 2addbe55c94b1131980b757ce4de983168057269 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 24 Jun 2026 00:19:16 +0200 Subject: [PATCH 068/696] sessions: show New Session button in titlebar when sidebar is hidden (#322614) * sessions: show New Session button in titlebar when sidebar is hidden Surface the compact "New" session affordance in the titlebar's left section when the sessions list (sidebar) is hidden, mirroring the button in the sessions sidebar header. This is done with a single action (NEW_SESSION_ACTION_ID) contributed to both Menus.SidebarSessionsHeader and Menus.TitleBarLeftLayout, rendered by one shared NewSessionActionViewItem registered for both menus via IActionViewItemService. The view item inlines the compact "New" pill (label + live keybinding hint + hover) so both surfaces render the exact same widget. The titlebar entry is gated to only show when the sidebar is hidden, since the list has its own button. The sidebar header's hand-placed button is removed in favor of the menu-driven action view item, and the button CSS now lives in an unscoped media/newSessionActionViewItem.css so it renders identically wherever it is mounted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address CCR feedback on new-session action view item - Prevent double session creation: stop click propagation so the inner button click no longer also triggers BaseActionViewItem's
  • click handler, and run through the action runner (gated on the action's enabled state) instead of calling the command service directly. - Use var(--vscode-strokeThickness) instead of a hard-coded 1px border width to stay consistent with the repo's stroke conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 2 +- .../contrib/chat/browser/chat.contribution.ts | 22 ++- .../media/newSessionActionViewItem.css | 89 +++++++++ .../browser/media/sessionsViewPane.css | 74 +------- .../browser/newSessionActionViewItem.ts | 171 ++++++++++++++++++ .../sessions/browser/sessions.contribution.ts | 2 + .../sessions/browser/views/sessionsView.ts | 97 +--------- 7 files changed, 289 insertions(+), 168 deletions(-) create mode 100644 src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css create mode 100644 src/vs/sessions/contrib/sessions/browser/newSessionActionViewItem.ts diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index f4a541acb1ded..6d7807be922be 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -83,7 +83,7 @@ The titlebar is a standalone implementation (`TitlebarPart`) — not extending ` | Section | Menu ID | Content | |---------|---------|---------| -| Left | `Menus.TitleBarLeftLayout` | Toggle sidebar, agent host filter | +| Left | `Menus.TitleBarLeftLayout` | Toggle sidebar, new session (when sidebar hidden), agent host filter | | Center | `Menus.CommandCenter` | Session picker widget (plus `Menus.TitleBarSessionMenu` for active-session actions) | | Right | `Menus.TitleBarRightLayout` | Remote connections, run script (split button), Open Terminal/VS Code, toggle auxiliary bar, account widget | diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index f7dbc6647f831..577a318642cd4 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -7,6 +7,7 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -37,8 +38,10 @@ import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SE import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { EditorAreaFocusContext } from '../../../../workbench/common/contextkeys.js'; +import { EditorAreaFocusContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { NEW_SESSION_ACTION_ID } from '../common/constants.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { Menus } from '../../../browser/menus.js'; class NewChatInSessionsWindowAction extends Action2 { @@ -61,7 +64,22 @@ class NewChatInSessionsWindowAction extends Action2 { primary: KeyMod.CtrlCmd | KeyCode.KeyN, secondary: [KeyMod.WinCtrl | KeyCode.KeyL] }, - } + }, + menu: [ + { + id: Menus.SidebarSessionsHeader, + group: 'navigation', + // Render before the filter (order 10) and find (order 20) + // actions so the sessions sidebar header reads: New, Filter, Find. + order: 0, + }, + { + id: Menus.TitleBarLeftLayout, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(SideBarVisibleContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) + } + ] }); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css b/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css new file mode 100644 index 0000000000000..2f812857295dd --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Compact "New" session button — rendered by NewSessionActionViewItem and shared +between the sessions sidebar header and the titlebar. Selectors are intentionally +unscoped so the widget renders identically wherever it is mounted. */ + +.agent-sessions-compact-new-button.monaco-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 2px 8px; + font-size: var(--vscode-agents-fontSize-label1, 12px); + line-height: 18px; + border-radius: var(--vscode-cornerRadius-small); + border: var(--vscode-strokeThickness) solid var(--vscode-agentsNewSessionButton-border, var(--vscode-button-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent))); + background-color: var(--vscode-agentsNewSessionButton-background, transparent); + color: var(--vscode-agentsNewSessionButton-foreground, var(--vscode-foreground)); + width: auto; + min-width: 0; + height: auto; + white-space: nowrap; +} + +.agent-sessions-compact-new-button.monaco-button:not(.disabled) { + cursor: pointer; +} + +/* When mounted in the titlebar, add breathing room from the adjacent +sidebar toggle and the surrounding titlebar edges. */ +.sessions-titlebar-new-session-item { + display: inline-flex; + align-items: center; + margin: 0 4px; +} + +.agent-sessions-compact-new-button.monaco-button.default-colors:hover { + background-color: var(--vscode-agentsNewSessionButton-hoverBackground, var(--vscode-toolbar-hoverBackground)); +} + +.agent-sessions-compact-new-button.monaco-button:focus { + outline-offset: -1px !important; +} + +.agent-sessions-compact-new-button .new-session-button-label { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + white-space: nowrap; +} + +.agent-sessions-compact-new-button .new-session-keybinding-hint { + display: inline-block; + flex-shrink: 0; + font-family: var(--monaco-monospace-font); + font-size: var(--vscode-agents-fontSize-label3, 10px); + line-height: 1; + padding: 2px 4px; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-xSmall); + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + box-shadow: none; +} + +.agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding { + line-height: 1; +} + +.agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key { + margin: 0 1px; + padding: 0; + min-width: auto; + border: 0; + background-color: transparent; + box-shadow: none; +} + +.agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key:first-child { + margin-left: 0; +} + +.agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key:last-child { + margin-right: 0; +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index ca84c0ec9a703..4931b0be49478 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -149,79 +149,7 @@ justify-content: center; } - /* Compact "New" button in header */ - .agent-sessions-compact-new-button.monaco-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 2px 8px; - font-size: var(--vscode-agents-fontSize-label1, 12px); - line-height: 18px; - border-radius: var(--vscode-cornerRadius-small); - border: 1px solid var(--vscode-agentsNewSessionButton-border, var(--vscode-button-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent))); - background-color: var(--vscode-agentsNewSessionButton-background, transparent); - color: var(--vscode-agentsNewSessionButton-foreground, var(--vscode-foreground)); - width: auto; - min-width: 0; - height: auto; - white-space: nowrap; - } - - .agent-sessions-compact-new-button.monaco-button:not(.disabled) { - cursor: pointer; - } - - .agent-sessions-compact-new-button.monaco-button.default-colors:hover { - background-color: var(--vscode-agentsNewSessionButton-hoverBackground, var(--vscode-toolbar-hoverBackground)); - } - - .agent-sessions-compact-new-button.monaco-button:focus { - outline-offset: -1px !important; - } - - .agent-sessions-compact-new-button .new-session-button-label { - display: inline-flex; - align-items: center; - gap: 2px; - min-width: 0; - white-space: nowrap; - } - - .agent-sessions-compact-new-button .new-session-keybinding-hint { - display: inline-block; - flex-shrink: 0; - font-family: var(--monaco-monospace-font); - font-size: var(--vscode-agents-fontSize-label3, 10px); - line-height: 1; - padding: 2px 4px; - border: var(--vscode-strokeThickness) solid transparent; - border-radius: var(--vscode-cornerRadius-xSmall); - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - box-shadow: none; - } - - .agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding { - line-height: 1; - } - - .agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key { - margin: 0 1px; - padding: 0; - min-width: auto; - border: 0; - background-color: transparent; - box-shadow: none; - } - - .agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key:first-child { - margin-left: 0; - } - - .agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key:last-child { - margin-right: 0; - } + /* Compact "New" button in header — styled in media/newSessionActionViewItem.css so the same widget can also render in the titlebar. */ .agent-sessions-control-container { flex: 1; diff --git a/src/vs/sessions/contrib/sessions/browser/newSessionActionViewItem.ts b/src/vs/sessions/contrib/sessions/browser/newSessionActionViewItem.ts new file mode 100644 index 0000000000000..0ed9b5e5db63b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/newSessionActionViewItem.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append, EventHelper, reset } from '../../../../base/browser/dom.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'; +import { IAction } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { OS } from '../../../../base/common/platform.js'; +import { localize } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { asCssVariable } from '../../../../platform/theme/common/colorRegistry.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { Menus } from '../../../browser/menus.js'; +import { agentsNewSessionButtonBackground, agentsNewSessionButtonBorder, agentsNewSessionButtonForeground, agentsNewSessionButtonHoverBackground } from '../../../common/theme.js'; +import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; +import { NEW_SESSION_ACTION_ID } from '../../chat/common/constants.js'; +import './media/newSessionActionViewItem.css'; + +/** + * Renders the new-session action ({@link NEW_SESSION_ACTION_ID}) as the compact "New" pill + * with an inline keybinding hint. Used wherever the action is contributed — the sessions + * sidebar header and the titlebar — so both surfaces render the exact same affordance. + */ +class NewSessionActionViewItem extends BaseActionViewItem { + + constructor( + action: IAction, + private readonly inTitleBar: boolean, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IHoverService private readonly hoverService: IHoverService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(undefined, action); + } + + override render(container: HTMLElement): void { + super.render(container); + + if (!this.element) { + return; + } + + if (this.inTitleBar) { + this.element.classList.add('sessions-titlebar-new-session-item'); + } + + const newSessionButton = this._register(new Button(this.element, { + ...defaultButtonStyles, + buttonSecondaryBackground: asCssVariable(agentsNewSessionButtonBackground), + buttonSecondaryForeground: asCssVariable(agentsNewSessionButtonForeground), + buttonSecondaryHoverBackground: asCssVariable(agentsNewSessionButtonHoverBackground), + buttonSecondaryBorder: asCssVariable(agentsNewSessionButtonBorder), + secondary: true, + supportIcons: true, + })); + newSessionButton.element.classList.add('agent-sessions-compact-new-button'); + this._register(newSessionButton.onDidClick(e => { + // The inner button lives inside this view item's
  • , whose click + // listener (installed by BaseActionViewItem) would also run the action. + // Stop propagation so the command runs exactly once, and run through the + // action runner so the action's enabled state is respected. + EventHelper.stop(e, true); + if (!this.action.enabled) { + return; + } + logSessionsInteraction(this.telemetryService, 'newSession'); + this.actionRunner.run(this.action); + })); + + const newSessionLabel = localize('newCompact', "New"); + const buttonLabel = $('span.new-session-button-label', undefined, newSessionLabel); + const keybindingHint = $('span.new-session-keybinding-hint'); + const keybindingHintLabel = this._register(new KeybindingLabel(keybindingHint, OS, { + disableTitle: true, + keybindingLabelBackground: 'transparent', + keybindingLabelForeground: 'inherit', + keybindingLabelBorder: 'transparent', + keybindingLabelBottomBorder: undefined, + keybindingLabelShadow: undefined, + })); + reset(newSessionButton.element, buttonLabel); + + const getNewSessionKeybinding = () => { + const primaryKeybinding = this.keybindingService.lookupKeybinding(NEW_SESSION_ACTION_ID, this.contextKeyService, true); + const resolvedKeybindings = this.keybindingService.lookupKeybindings(NEW_SESSION_ACTION_ID); + return primaryKeybinding ?? resolvedKeybindings[0]; + }; + + this._register(this.hoverService.setupDelayedHover(newSessionButton.element, () => { + const keybindingLabel = getNewSessionKeybinding()?.getLabel() ?? undefined; + return { + content: keybindingLabel + ? localize('newSessionButtonTitle', "New Session ({0})", keybindingLabel) + : localize('newSessionButtonTitleWithoutKeybinding', "New Session"), + appearance: { compact: true }, + position: { hoverPosition: HoverPosition.BELOW }, + }; + })); + + let lastRenderedKeybindingLabel: string | undefined | null = null; + let lastRenderedKeybindingAriaLabel: string | undefined | null = null; + const updateNewSessionButton = () => { + const keybinding = getNewSessionKeybinding(); + const keybindingLabel = keybinding?.getLabel() ?? undefined; + const keybindingAriaLabel = keybinding?.getAriaLabel() ?? undefined; + if (lastRenderedKeybindingLabel === keybindingLabel && lastRenderedKeybindingAriaLabel === keybindingAriaLabel) { + return; + } + + lastRenderedKeybindingLabel = keybindingLabel; + lastRenderedKeybindingAriaLabel = keybindingAriaLabel; + + keybindingHintLabel.set(keybinding); + if (keybinding) { + if (keybindingHint.parentElement !== newSessionButton.element) { + append(newSessionButton.element, keybindingHint); + } + } else { + keybindingHint.remove(); + } + + newSessionButton.element.setAttribute('aria-label', keybindingAriaLabel + ? localize('newSessionButtonAriaLabel', "New Session ({0})", keybindingAriaLabel) + : localize('newSessionButtonAriaLabelWithoutKeybinding', "New Session")); + }; + this._register(Event.runAndSubscribe(this.keybindingService.onDidUpdateKeybindings, updateNewSessionButton)); + } +} + +/** + * Registers {@link NewSessionActionViewItem} for the new-session action in every menu that + * surfaces it (the sessions sidebar header and the titlebar's left toolbar). The factory is + * announced once right after registration so a toolbar that was built before this contribution + * ran re-renders and picks the widget up. + */ +export class NewSessionActionViewItemContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.newSessionActionViewItem'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + + const onDidRegister = this._register(new Emitter()); + const menus: MenuId[] = [Menus.SidebarSessionsHeader, Menus.TitleBarLeftLayout]; + for (const menu of menus) { + const inTitleBar = menu === Menus.TitleBarLeftLayout; + this._register(actionViewItemService.register(menu, NEW_SESSION_ACTION_ID, (action, _options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(NewSessionActionViewItem, action, inTitleBar); + }, onDidRegister.event)); + } + onDidRegister.fire(); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index f0e756fe39573..5a89e5a3bba93 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -12,6 +12,7 @@ import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js' import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; +import { NewSessionActionViewItemContribution } from './newSessionActionViewItem.js'; import { SessionsTelemetryContribution } from './sessionsTelemetry.contribution.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './views/sessionsViewActions.js'; @@ -54,4 +55,5 @@ const sessionsViewPaneDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(NewSessionActionViewItemContribution.ID, NewSessionActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTelemetryContribution.ID, SessionsTelemetryContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index 53bf391e79eb2..9f1a2b8feba46 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -6,10 +6,8 @@ import '../media/sessionsViewPane.css'; import * as DOM from '../../../../../base/browser/dom.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; -import { KeybindingLabel } from '../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { Event } from '../../../../../base/common/event.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { isWeb, OS } from '../../../../../base/common/platform.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; @@ -28,16 +26,10 @@ import { ISession, SessionStatus } from '../../../../services/sessions/common/se import { AICustomizationShortcutsWidget } from '../aiCustomizationShortcutsWidget.js'; import { AgentHostShortcutsWidget } from '../agentHostShortcutsWidget.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { Button } from '../../../../../base/browser/ui/button/button.js'; -import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; -import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; -import { asCssVariable } from '../../../../../platform/theme/common/colorRegistry.js'; -import { agentsBackground, agentsNewSessionButtonBackground, agentsNewSessionButtonBorder, agentsNewSessionButtonForeground, agentsNewSessionButtonHoverBackground } from '../../../../common/theme.js'; +import { agentsBackground } from '../../../../common/theme.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; -import { logSessionsInteraction } from '../../../../common/sessionsTelemetry.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; @@ -46,8 +38,6 @@ import { MobileSessionFilterChips } from '../../../../browser/parts/mobile/mobil import { IMobileSortGroupSheetItem, showMobileSortGroupSheet } from '../../../../browser/parts/mobile/mobileSortGroupSheet.js'; import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { NEW_SESSION_ACTION_ID } from '../../../chat/common/constants.js'; const $ = DOM.$; export const SessionsViewId = 'sessions.workbench.view.sessionsView'; @@ -109,8 +99,6 @@ export class SessionsView extends ViewPane { @IHostService private readonly hostService: IHostService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IStorageService private readonly storageService: IStorageService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @ICommandService private readonly commandService: ICommandService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -181,9 +169,9 @@ export class SessionsView extends ViewPane { if (!phoneLayout) { headerLabel.textContent = localize('sessionsHeader', "Sessions"); - // Header actions (visual order: New, Filter, Search) - this.createNewSessionButton(headerActions); - + // Header actions (visual order: New, Filter, Search). The "New" button is + // contributed to Menus.SidebarSessionsHeader and rendered as a compact pill + // by NewSessionActionViewItem. const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); this._register(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, headerActions, Menus.SidebarSessionsHeader, { hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -327,81 +315,6 @@ export class SessionsView extends ViewPane { } } - private createNewSessionButton(container: HTMLElement): void { - const newSessionButton = this._register(new Button(container, { - ...defaultButtonStyles, - buttonSecondaryBackground: asCssVariable(agentsNewSessionButtonBackground), - buttonSecondaryForeground: asCssVariable(agentsNewSessionButtonForeground), - buttonSecondaryHoverBackground: asCssVariable(agentsNewSessionButtonHoverBackground), - buttonSecondaryBorder: asCssVariable(agentsNewSessionButtonBorder), - secondary: true, - supportIcons: true, - })); - newSessionButton.element.classList.add('agent-sessions-compact-new-button'); - this._register(newSessionButton.onDidClick(() => { - logSessionsInteraction(this.telemetryService, 'newSession'); - this.commandService.executeCommand(NEW_SESSION_ACTION_ID); - })); - - const newSessionLabel = localize('newCompact', "New"); - const buttonLabel = $('span.new-session-button-label', undefined, newSessionLabel); - const keybindingHint = $('span.new-session-keybinding-hint'); - const keybindingHintLabel = this._register(new KeybindingLabel(keybindingHint, OS, { - disableTitle: true, - keybindingLabelBackground: 'transparent', - keybindingLabelForeground: 'inherit', - keybindingLabelBorder: 'transparent', - keybindingLabelBottomBorder: undefined, - keybindingLabelShadow: undefined, - })); - DOM.reset(newSessionButton.element, buttonLabel); - - const getNewSessionKeybinding = () => { - const primaryKeybinding = this.keybindingService.lookupKeybinding(NEW_SESSION_ACTION_ID, this.scopedContextKeyService, true); - const resolvedKeybindings = this.keybindingService.lookupKeybindings(NEW_SESSION_ACTION_ID); - return primaryKeybinding ?? resolvedKeybindings[0]; - }; - - this._register(this.hoverService.setupDelayedHover(newSessionButton.element, () => { - const keybindingLabel = getNewSessionKeybinding()?.getLabel() ?? undefined; - return { - content: keybindingLabel - ? localize('newSessionButtonTitle', "New Session ({0})", keybindingLabel) - : localize('newSessionButtonTitleWithoutKeybinding', "New Session"), - appearance: { compact: true }, - position: { hoverPosition: HoverPosition.BELOW }, - }; - })); - - let lastRenderedKeybindingLabel: string | undefined | null = null; - let lastRenderedKeybindingAriaLabel: string | undefined | null = null; - const updateNewSessionButton = () => { - const keybinding = getNewSessionKeybinding(); - const keybindingLabel = keybinding?.getLabel() ?? undefined; - const keybindingAriaLabel = keybinding?.getAriaLabel() ?? undefined; - if (lastRenderedKeybindingLabel === keybindingLabel && lastRenderedKeybindingAriaLabel === keybindingAriaLabel) { - return; - } - - lastRenderedKeybindingLabel = keybindingLabel; - lastRenderedKeybindingAriaLabel = keybindingAriaLabel; - - keybindingHintLabel.set(keybinding); - if (keybinding) { - if (keybindingHint.parentElement !== newSessionButton.element) { - DOM.append(newSessionButton.element, keybindingHint); - } - } else { - keybindingHint.remove(); - } - - newSessionButton.element.setAttribute('aria-label', keybindingAriaLabel - ? localize('newSessionButtonAriaLabel', "New Session ({0})", keybindingAriaLabel) - : localize('newSessionButtonAriaLabelWithoutKeybinding', "New Session")); - }; - this._register(Event.runAndSubscribe(this.keybindingService.onDidUpdateKeybindings, updateNewSessionButton)); - } - focusCustomizations(): void { this._customizationsWidget?.focus(); } From fc03e3346d592b493ddadaef8e65995ab7f3dd79 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 23 Jun 2026 18:20:55 -0400 Subject: [PATCH 069/696] Fix voice transcript overlay persisting across session switches (#322615) --- .../browser/widgetHosts/viewPane/chatViewPane.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 b61c691c4d35a..1018670abaccf 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -13,7 +13,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../ba import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; -import { autorun, IReader } from '../../../../../../base/common/observable.js'; +import { autorun, IReader, observableValue } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -109,6 +109,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private readonly modelRef = this._register(new MutableDisposable()); private readonly activityBadge = this._register(new MutableDisposable()); + private readonly _currentSessionResource = observableValue(this, undefined); constructor( options: IViewPaneOptions, @@ -561,6 +562,15 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return; } + // Don't show transcript from a different session in this widget + const targetSession = this.voiceSessionController.targetSession.read(reader); + const currentSession = this._currentSessionResource.read(reader); + if (targetSession && currentSession && !isEqual(targetSession, currentSession)) { + transcriptOverlayNode.style.display = 'none'; + transcriptOverlayNode.classList.remove('has-transcript'); + return; + } + // Show hint when connected but no transcript yet if (visible.length === 0 || !showTranscript) { const autoSendDelay = this.configurationService.getValue('agents.voice.autoSendDelay') ?? 500; @@ -895,6 +905,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._register(chatWidget.onDidChangeViewModel(() => { const model = chatWidget.viewModel?.model; this.titleControl?.update(model); + this._currentSessionResource.set(chatWidget.viewModel?.sessionResource, undefined); if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { return; // only reveal in side-by-side mode From 468a0514576bf2eceb2895c84ac33c3f3e98e9a1 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:44:00 +0200 Subject: [PATCH 070/696] Agents - fix bug with operations not being shown for Last Turn Changes (#322631) --- src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 3f7ecec5acf7f..ecfa712eace4e 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -185,7 +185,6 @@ export class AgentHostChangesetCoordinator extends Disposable { // subsequent deltas flow from `onToolCallEditsApplied` / // `onTurnComplete` once we've added this turn id here. this._addSubscription(parsed.sessionUri, resourceStr); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); return; } if (!parsed && this._stateManager.getSessionState(resourceStr)) { @@ -289,6 +288,7 @@ export class AgentHostChangesetCoordinator extends Disposable { await this.restoreSessionIfChangesetSubscription(resource, restoreSession); if (parsed.kind === ChangesetKind.Turn && parsed.turnId) { await this._changesets.computeTurnChangeset(parsed.sessionUri, parsed.turnId); + this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); } else if (parsed.kind === ChangesetKind.Compare && parsed.originalTurnId && parsed.modifiedTurnId) { // Compare-turns is computed once on subscribe. Both turns are // typically historical so the snapshot doesn't need to track From cf7e43e1aaf4d4b2998b2d0f8e2b7d0410517052 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 24 Jun 2026 00:02:05 +0100 Subject: [PATCH 071/696] apply review comments (#322555) --- .../chat/browser/tools/languageModelToolsService.ts | 12 ++++++------ .../chat/common/tools/languageModelToolsService.ts | 5 +++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 3a25dfd04d43f..9510839e6dee9 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -1639,27 +1639,27 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const toolsCoveredByEnabledToolSet = new Set(); // compare by id as toolset instances may be different (e.g. ToolSetForModel) - const eneabledToolSetIds = new Set(); - const eneabledToolIds = new Set(); + const enabledToolSetIds = new Set(); + const enabledToolIds = new Set(); for (const [tool, enabled] of map) { if (enabled) { if (isToolSet(tool)) { - eneabledToolSetIds.add(tool.id); + enabledToolSetIds.add(tool.id); } else { - eneabledToolIds.add(tool.id); + enabledToolIds.add(tool.id); } } } for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { if (isToolSet(tool)) { - if (eneabledToolSetIds.has(tool.id)) { + if (enabledToolSetIds.has(tool.id)) { result.push(fullReferenceName); for (const memberTool of tool.getTools()) { toolsCoveredByEnabledToolSet.add(memberTool); } } } else { - if (eneabledToolIds.has(tool.id) && !toolsCoveredByEnabledToolSet.has(tool)) { + if (enabledToolIds.has(tool.id) && !toolsCoveredByEnabledToolSet.has(tool)) { result.push(fullReferenceName); } } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 0383fee659c76..8f1024705ce53 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -418,8 +418,9 @@ export interface IToolSet { /** - * Maps tools and tool sets to their enablement state. Use a class to make control the creation of the map and ensure that it is not mutated after creation. - */ +* Maps tools and tool sets to their enablement state. Use a class to control creation of the map and ensure +* that it is not mutated after creation. +*/ export class ToolAndToolSetEnablementMap implements Iterable<[IToolData | IToolSet, boolean]> { static fromEntries(entries: Iterable<[IToolData | IToolSet, boolean]>): ToolAndToolSetEnablementMap { From 27725b9a6380582f63485556f3484c0ba4c4347f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 24 Jun 2026 01:11:39 +0200 Subject: [PATCH 072/696] sessions: resolve ${workspaceFolder} in agent-host tasks (#322629) * sessions: resolve ${workspaceFolder} in agent-host tasks via IConfigurationResolverService Agent-host worktree sessions build their task command line through AgentHostSessionTaskRunner/resolveTaskCommand, which bypassed variable resolution and sent ${workspaceFolder} to the terminal verbatim (causing EROFS errors writing to a literal "${workspaceFolder}" path). Expand variables using the workbench IConfigurationResolverService (already registered in the Agents window), passing the session's worktree folder so ${workspaceFolder} resolves to the worktree rather than the Agents window's own workspace. The resolver API is async, so the resolveTaskCommand pipeline is now async; the hook is attached only when a cwd is known so variables are left untouched otherwise. Also adds the Agents-window worktree dev tasks (Install & Watch Client, Run Client, Install & Watch) to .vscode/tasks.json, and tightens the over-commenting guidance in the coding guidelines and sessions skill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address CCR feedback on agent-host variable resolution - Resolve ${workspaceFolder} for remote hosts using the POSIX URI path instead of IConfigurationResolverService.resolveAsync (whose fsPath uses renderer-OS separators, wrong for a differently-OS'd remote host). - Wrap the local resolveAsync call in try/catch so an unresolvable variable (e.g. ${command:}/${input:}) leaves the string unchanged rather than failing task dispatch. - Fix the _getCwd comment to reference the real scheme (vscode-agent-host). - Add a remote-host test asserting POSIX-path expansion and that the renderer resolver is not consulted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: make agent-host task runner test platform-independent The expansion test asserted against the resolver substituting URI.fsPath, which on Windows yields backslash separators. Backslash is in POSIX_NEEDS_QUOTING, so the expanded arg was strong-quoted on Windows and the unquoted assertion failed. Substitute the POSIX URI path instead so the test is deterministic across platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../coding-guidelines.instructions.md | 5 + .github/skills/sessions/SKILL.md | 4 +- .vscode/tasks.json | 114 ++++++++++++++- .../contrib/chat/browser/taskCommand.ts | 78 +++++----- .../chat/test/browser/taskCommand.test.ts | 134 ++++++++++++------ .../browser/agentHostSessionTaskRunner.ts | 50 +++++-- .../agentHostSessionTaskRunner.test.ts | 52 +++++++ 7 files changed, 328 insertions(+), 109 deletions(-) diff --git a/.github/instructions/coding-guidelines.instructions.md b/.github/instructions/coding-guidelines.instructions.md index 17f9662b87cb5..cbab74790541f 100644 --- a/.github/instructions/coding-guidelines.instructions.md +++ b/.github/instructions/coding-guidelines.instructions.md @@ -28,6 +28,11 @@ Use tabs, not spaces. - Use JSDoc style comments for functions, interfaces, enums, and classes - Do not write large comments in the middle of a method or comments that explain a single line. If a line or block needs a paragraph of explanation to be understood, treat that as a signal that the code itself is unclear: extract a well-named function, introduce an explanatory variable, or simplify the logic instead. Keep any remaining inline comment to a brief note. +- **Default to NO comment.** Add one only when the code cannot be made self-explanatory by naming. Code that is obvious from its identifiers must not be commented. +- **Hard limits** (treat as rules, not suggestions): + - JSDoc on a function/interface/property: at most 1–2 short sentences. Do not enumerate every branch/feature, restate the signature, list what it does NOT do, or explain parameters that are obvious from their names/types. + - Inline comment inside a method body: at most 1 line, and only for a genuine workaround/hack, a non-obvious ordering constraint, or a surprising side effect. Never narrate the next statement (e.g. `// Expand the variable`, `// loop over args`). +- Before writing any comment longer than one line, stop and either delete it or shorten it to a single line. A multi-line block comment inside a method body is almost always wrong. ## Strings diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index e02331b1305c3..bf9c29e742689 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -35,7 +35,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **Timeouts as fixes**: Never use `setTimeout`/`disposableTimeout`/arbitrary delays to fix bugs or implement behaviour. They are race-prone guesses that mask the real ordering/state problem. Drive logic off deterministic signals instead — observables (`autorun`/`derived`), explicit events (`onDidChange*`), lifecycle phases, or awaiting the actual async operation. - **Stashed state read back later (side-channels)**: Never stash a value on a service during one method call and read it back from a separate query later, assuming it is still valid (e.g. a `Set`/flag set in `openSession` and consumed by a `shouldX()` pull-API). This is fragile temporal coupling. Instead, make it reactive state that is set **atomically together with its source of truth** and consumed reactively. Example: per-activation intent like "open in background / preserve focus" is exposed as an `IObservable` set in the **same transaction** as `activeSession` (via a single internal setter so it can never go stale), and read with `.read(reader)` in the consumer's `autorun` — never via a consume-once getter. - **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile. Do **not** bound the upgrade with a timeout or even a lifecycle milestone like `LifecyclePhase.Eventually` — an agent host connects lazily and can surface its session types arbitrarily late, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead. -- **Over-commenting**: Don't write long explanatory comments narrating what the code does or justifying ordinary patterns. Per the coding guidelines, only comment code that needs clarification — reserve comments for genuine workarounds/hacks or non-obvious intent, and keep them to a line or two. +- **Over-commenting**: Don't write long explanatory comments narrating what the code does or justifying ordinary patterns. **Hard rules**: JSDoc = 1–2 short sentences max (never enumerate every branch/feature, restate the signature, or list what the function does NOT do); inline method comments = 1 line max, only for a genuine workaround/non-obvious constraint, never to narrate the next statement. Default to no comment — if code needs a paragraph to explain, rename/extract instead. Before writing any comment longer than one line, delete it or shorten it to one line. - **Inserting/removing DOM on demand for transient UI (e.g. inline rename inputs)**: Don't `insertBefore`/`appendChild`+`remove()` a widget on the *tab/row element itself* when an interaction starts/ends — that churns the parent's child list and depends on event ordering during teardown. Also don't eagerly build a heavy widget (e.g. an `InputBox`) per row "just in case", since most rows never use it. Instead, create a **stable, empty container** alongside the label once, toggle its visibility via a CSS class on the row (e.g. `.editing`), and create the widget **inside that container lazily** only while editing — disposing it and emptying the container (`reset(container)`) when done (`InputBox.dispose()` does not detach its own node). Prefer the shared themed widget (`InputBox` + `defaultInputBoxStyles`) over a hand-rolled ``. - **Collapsing distinct provider identities in pickers**: Do not collapse extension-backed chat session ids (e.g. `copilotcli`) and agent-host ids (e.g. `agent-host-copilotcli`) based only on friendly names or well-known provider enums. They can coexist in the Agents window and route to different infrastructure; keep the exact session type id through selection/delegation and hide ambiguous legacy targets when an agent-host target supersedes them. - **Resolving a session's provider via the create-only tracking map**: On the agent host, resolve the owning provider for any per-session operation (createChat, disposeChat, sendMessage, …) through `AgentService._findProviderForSession`, never the raw `_sessionToProvider` map. That map is populated only by `createSession`, so a **restored** session (alive in the state manager after a host restart but never created in this process) is absent from it — a direct lookup throws `no provider for session` and silently breaks the feature (e.g. Add Chat did nothing for restored sessions while messaging worked, because messaging already used the fallback). `_findProviderForSession` falls back to the session URI's scheme provider, which is what makes restored sessions work. @@ -44,6 +44,8 @@ Then read the relevant spec for the area you are changing (see table below). If - **Derive SDK custom-agent names exactly like `parseAgentFile`**: `_resolveAgentName` resolves the selected agent through the plugin parser, which trims the frontmatter `name` (`getStringValue('name')?.trim() || nameFromFile`). When building the SDK `customAgents` list (`toSdkCustomAgents`), derive the name the same way (`?.trim() || agent.name`); reading the raw frontmatter `name` without trimming yields a config name that won't match the trimmed `resolvedAgentName`, so the SDK still rejects the session with `Custom agent '' not found`. - **Peer chats have no server `summary`, so dedup side-channel dispatch against the last value sent for that chat**: equality guards before dispatching `SessionModelChanged`/`SessionAgentChanged` compare against `summary.model`/`summary.agent`, which only exist for the session's default chat. For peer chats, track the last-dispatched model/agent on the `AgentHostChatSession` instance (auto-cleaned on dispose) and diff against that — otherwise every peer-chat turn redundantly re-dispatches (and re-resolves the agent), and an intentional "clear selection" (`undefined`) can't be detected. - **Scrollable transcript surfaces must use workbench scrollbars**: Don't make Agents/voice transcript regions scrollable with native `overflow-y: auto` on the content node. Wrap transcript content in `DomScrollableElement`/list widgets so scrollbars match VS Code theming and remain usable in narrow auxiliary-window layouts. +- **Background-sending a multi-chat composer must reset the composer *before* dispatching the send, not concurrently**: in `NewChatInSessionWidget._send`, creating the replacement untitled chat (`openNewChatInSession({ forceNew: true })` → `provider.createNewChat`) and the fire-and-forget background `sendRequest` both reach into shared chat-session state (`acquireOrLoadSession` / `getOrCreateChatSession`) for chats in the **same group**. Running them concurrently (send first, reset second) raced and left the sent chat stuck spinning with its message never dispatched, plus a second empty "New Chat" tab. Fully `await` the composer reset first, then fire the background send so it runs on its own. +- **Chat tab order belongs in the renderer, not the providers**: providers report chats in unstable orders — the agent host re-sorts its `state.chats` catalog when a chat finishes a turn (moving the just-completed one last). Don't try to fix a "tab jumps to the end on completion" bug inside one provider (it won't cover the others). Instead keep the provider's order in the renderer's rebuild autorun (`chatCompositeBar.ts`) and only move in-composer `Untitled` chats to the end. ## Capturing Feedback (meta-rule) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ff8519414e277..9a4bd1d307687 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,111 @@ { "version": "2.0.0", "tasks": [ + { + "label": "Install & Watch (Client)", + "detail": "Install dependencies, compile and watch VS Code core for fixing a bug or implementing a feature.", + "dependsOn": [ + "Install Dependencies", + "Compile", + "Watch Client Transpile" + ], + "dependsOrder": "sequence", + "inAgents": true, + "problemMatcher": [] + }, + { + "label": "Run VS Code", + "detail": "Run VS Code with a user data directory inside the worktree.", + "type": "shell", + "command": "./scripts/code.sh", + "windows": { + "command": ".\\scripts\\code.bat" + }, + "args": [ + "--user-data-dir=${workspaceFolder}/.profile-oss", + "--extensions-dir=${workspaceFolder}/.profile-oss/extensions" + ], + "inAgents": true, + "problemMatcher": [] + }, + { + "label": "Install & Watch (All)", + "detail": "Install dependencies and watch all of VS Code (core, extensions and copilot).", + "dependsOn": [ + "Install Dependencies", + "Watch" + ], + "dependsOrder": "sequence", + "inAgents": true, + "problemMatcher": [] + }, + { + "label": "Install Dependencies", + "type": "shell", + "command": "npm install", + "windows": { + "command": "cmd /c \"npm install\"" + }, + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": [] + }, + { + "label": "Compile", + "type": "npm", + "script": "compile", + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": "$tsc" + }, + { + "label": "Watch", + "type": "npm", + "script": "watch", + "isBackground": true, + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": [] + }, + { + "label": "Watch Client Transpile", + "type": "npm", + "script": "watch-client-transpile", + "isBackground": true, + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": { + "owner": "esbuild", + "applyTo": "closedDocuments", + "fileLocation": [ + "relative", + "${workspaceFolder}/src" + ], + "pattern": { + "regexp": "^(.+?):(\\d+):(\\d+): ERROR: (.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + }, + "background": { + "beginsPattern": "Starting transpilation\\.\\.\\.", + "endsPattern": "Finished transpilation with" + } + } + }, { "type": "npm", "script": "watch-client-transpiled", @@ -294,7 +399,6 @@ "Run Dev Agents" ], "dependsOrder": "sequence", - "inAgents": true, "problemMatcher": [] }, { @@ -304,7 +408,6 @@ "Run Dev" ], "dependsOrder": "sequence", - "inAgents": true, "problemMatcher": [] }, { @@ -445,8 +548,7 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "problemMatcher": [], - "inAgents": true + "problemMatcher": [] }, { "type": "npm", @@ -477,8 +579,7 @@ "beginsPattern": "Starting compilation\\.\\.\\.", "endsPattern": "Finished compilation with" } - }, - "inAgents": true + } }, { "label": "Component Explorer Server", @@ -509,7 +610,6 @@ "windows": { "command": "cmd /c \"npm install && npm run watch-transpile\"" }, - "inAgents": true, "presentation": { "focus": false, "reveal": "never" diff --git a/src/vs/sessions/contrib/chat/browser/taskCommand.ts b/src/vs/sessions/contrib/chat/browser/taskCommand.ts index b36bd27fbfadb..4e40d0489c0c6 100644 --- a/src/vs/sessions/contrib/chat/browser/taskCommand.ts +++ b/src/vs/sessions/contrib/chat/browser/taskCommand.ts @@ -42,6 +42,18 @@ export interface ITaskResolutionContext { * declared in any reachable `tasks.json`. */ readonly lookup?: (label: string) => ITaskEntry | undefined; + /** + * Optional hook that expands variables (e.g. `${workspaceFolder}`) before + * shell-quoting. When omitted, variables are left as-is. + */ + readonly resolveVariables?: (value: string) => Promise; +} + +/** + * Applies the caller-supplied variable resolver to a raw string, if any. + */ +function expandVariables(value: string, ctx: ITaskResolutionContext): Promise | string { + return ctx.resolveVariables ? ctx.resolveVariables(value) : value; } /** @@ -63,14 +75,15 @@ function posixEscape(value: string): string { return value.replace(/([\\\s"'`$&|;<>(){}[\]*?#~!])/g, '\\$1'); } -function renderArg(arg: CommandString): string { +async function renderArg(arg: CommandString, ctx: ITaskResolutionContext): Promise { if (typeof arg === 'string') { - return POSIX_NEEDS_QUOTING.test(arg) ? posixStrong(arg) : arg; + const value = await expandVariables(arg, ctx); + return POSIX_NEEDS_QUOTING.test(value) ? posixStrong(value) : value; } if (Array.isArray(arg)) { - return arg.map(renderArg).join(' '); + return (await Promise.all(arg.map(a => renderArg(a, ctx)))).join(' '); } - const value = CommandString.value(arg); + const value = await expandVariables(CommandString.value(arg), ctx); switch (arg.quoting) { case 'strong': return posixStrong(value); case 'weak': return posixWeak(value); @@ -82,16 +95,16 @@ function renderArg(arg: CommandString): string { /** * Resolves a task entry's own `command`/`script` (ignoring `dependsOn`). */ -function resolveOwnCommand(task: ITaskEntry, targetOS?: TaskTargetOS): string | undefined { - const override = targetOS ? task[targetOS] as { command?: string; args?: CommandString[] } | undefined : undefined; +async function resolveOwnCommand(task: ITaskEntry, ctx: ITaskResolutionContext): Promise { + const override = ctx.targetOS ? task[ctx.targetOS] as { command?: string; args?: CommandString[] } | undefined : undefined; const command = override?.command ?? task.command; const args = override?.args ?? task.args; if (command) { - const parts: string[] = [command]; + const parts: string[] = [await expandVariables(command, ctx)]; if (args) { for (const arg of args) { - parts.push(renderArg(arg)); + parts.push(await renderArg(arg, ctx)); } } return parts.join(' '); @@ -105,14 +118,10 @@ function resolveOwnCommand(task: ITaskEntry, targetOS?: TaskTargetOS): string | } /** - * Resolves a task's `dependsOn` chain into a single shell snippet, recursing - * through each dependency. - * - * Returns `undefined` if no dependency could be resolved. Self-referencing or - * cyclic chains are broken by tracking the active resolution stack — the - * cycling task contributes `undefined` and the rest of the chain proceeds. + * Resolves a task's `dependsOn` chain into a single shell snippet. Returns + * `undefined` if nothing resolves; cyclic chains are broken via `stack`. */ -function resolveDependencies(task: ITaskEntry, ctx: ITaskResolutionContext, stack: Set): string | undefined { +async function resolveDependencies(task: ITaskEntry, ctx: ITaskResolutionContext, stack: Set): Promise { if (!task.dependsOn || !ctx.lookup) { return undefined; } @@ -123,7 +132,7 @@ function resolveDependencies(task: ITaskEntry, ctx: ITaskResolutionContext, stac if (!dep) { continue; } - const cmd = resolveInternal(dep, ctx, stack); + const cmd = await resolveInternal(dep, ctx, stack); if (cmd) { resolved.push(cmd); } @@ -134,25 +143,21 @@ function resolveDependencies(task: ITaskEntry, ctx: ITaskResolutionContext, stac if (resolved.length === 1) { return resolved[0]; } - // `parallel` is rendered as backgrounded subshells joined by `&`, with a - // trailing `wait` so the overall command only completes when every - // dependency does. Output interleaving is unavoidable but matches the - // semantics of the Tasks extension. `sequence` (and the unspecified - // default) chain with `&&` so a failing dependency short-circuits. + // parallel: backgrounded subshells + `wait`. sequence/default: `&&` chain. return task.dependsOrder === 'parallel' ? `${resolved.map(c => `( ${c} )`).join(' & ')} & wait` : resolved.join(' && '); } -function resolveInternal(task: ITaskEntry, ctx: ITaskResolutionContext, stack: Set): string | undefined { +async function resolveInternal(task: ITaskEntry, ctx: ITaskResolutionContext, stack: Set): Promise { if (stack.has(task.label)) { // Cycle — break here. Other branches of the chain still resolve. return undefined; } stack.add(task.label); try { - const own = resolveOwnCommand(task, ctx.targetOS); - const deps = resolveDependencies(task, ctx, stack); + const own = await resolveOwnCommand(task, ctx); + const deps = await resolveDependencies(task, ctx, stack); if (own && deps) { return `${deps} && ${own}`; } @@ -164,27 +169,14 @@ function resolveInternal(task: ITaskEntry, ctx: ITaskResolutionContext, stack: S /** * Resolves an `ITaskEntry` into a single shell command line that can be sent - * to a terminal verbatim. - * - * This is intentionally a much smaller surface than the full Tasks extension - * task resolution: it handles - * - explicit `command` + `args` (including OS-specific `windows`/`osx`/`linux` overrides), - * - `type: 'npm'` + `script` → `npm run