diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index 1b9a1260794..7fa76a7e3a1 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -25,7 +25,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l |--------|---------------|----------------------| | **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | | **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | -| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM (`INativeManagedSettingsService`) and a file on disk (`IFileManagedSettingsService`) are **separate** inputs that `AccountPolicyService` selects among in `getPolicyData()` via `selectManagedSettings(server, nativeMdm, file)` (single authoritative source by precedence server > native MDM > file; no merging between layers) | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM (`INativeManagedSettingsService`) and a file on disk (`IFileManagedSettingsService`) are **separate** inputs that `AccountPolicyService` selects among in `getPolicyData()` via `selectManagedSettings(nativeMdm, server, file)` (single authoritative source by precedence native MDM > server > file; no merging between layers) | | **Copilot managed settings (native MDM)** | `NativeManagedSettingsService` via `@vscode/policy-watcher` | Watches `SOFTWARE\Policies\GitHubCopilot` (Windows) / `com.github.copilot` prefs (macOS); feeds the canonical `managedSettings` bag — see [github-managed-settings.md](./github-managed-settings.md) | | **Copilot managed settings (file)** | `FileManagedSettingsService` | Reads + watches `managed-settings.json` from a well-known per-OS path in the main process, exposed to renderers over IPC; lowest-precedence managed-settings channel — see [github-managed-settings.md](./github-managed-settings.md) | | **Multiplex** | `MultiplexPolicyService` | In the main process, combines multiple OS/file policy readers; in desktop and Agents-window renderers, combines the main-process `PolicyChannelClient` with `AccountPolicyService` | @@ -41,7 +41,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l | `src/vs/platform/policy/common/fileManagedSettingsService.ts` | File-based channel: reads + watches `managed-settings.json` on a well-known per-OS path, normalizes via `normalizeManagedSettings` | | `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values; parses JSON-string managed settings back to typed values; applies values to `policyReference` settings | | `src/vs/platform/configuration/common/configurationRegistry.ts` | `policy` / `policyReference` registration; `getPolicyReferenceConfigurations()` (name → subordinate settings) | -| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (server over MDM; single authoritative layer) | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (native MDM over server; single authoritative layer) | | `src/vs/workbench/services/accounts/browser/managedSettings.ts` | `adaptManagedSettings` — normalizes the server `managed_settings` response into the canonical bag | | `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | | `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | @@ -329,4 +329,4 @@ Search the codebase for `policy:` to find all the examples of different policy c * Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree. * **Regenerate `policyData.jsonc` in a clean environment, or the `PolicyExport` integration test will fail in CI.** `referencedSettings` is only captured for references **loaded at export time**. A plain `npm run export-policy-data` loads your **dev-profile extensions** (e.g. the Copilot extension), which injects `referencedSettings` onto core policies that the test's **fixture-based** export (clean profile, no user extensions) won't produce — so the checked-in file ends up with extra `referencedSettings` and CI fails. This is **not reproducible locally** because the test reuses your default extensions dir. Regenerate the way the test exports: `DISTRO_PRODUCT_JSON= ./scripts/code.sh --export-policy-data="$PWD/build/lib/policies/policyData.jsonc" --user-data-dir="$(mktemp -d)" --extensions-dir="$(mktemp -d)"` (with `VSCODE_SKIP_PRELAUNCH=1`). -* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "server-delivered managed settings win over native MDM; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. +* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "native MDM managed settings win over the server-delivered channel; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. diff --git a/.github/skills/add-policy/github-managed-settings.md b/.github/skills/add-policy/github-managed-settings.md index 2dd4735795d..fc630880223 100644 --- a/.github/skills/add-policy/github-managed-settings.md +++ b/.github/skills/add-policy/github-managed-settings.md @@ -52,8 +52,8 @@ described delivery slots (native MDM, server-managed, and file-based). All three VS Code channels converge in `AccountPolicyService.getPolicyData()`. -**Precedence: server-delivered managed settings win over native MDM, which in turn win -over the file-based channel** (`selectManagedSettings` in `copilotManagedSettings.ts`). +**Precedence: native MDM managed settings win over the server-delivered channel, which in +turn wins over the file-based channel** (`selectManagedSettings` in `copilotManagedSettings.ts`). There is a single authoritative source at any point in time — the channels are **not** merged. The highest-precedence non-empty channel wins outright and the rest are ignored. Rationale: the server is harder to bypass than local MDM, and a local file is the most @@ -201,7 +201,7 @@ delivery slot for the managed value. | `collectManagedSettingsDefinitions(policyDefinitions)` | Aggregates every policy's `managedSettings` into one `key → { type }` map. **Single source of truth** for which keys (and types) are honored; drives both the MDM watcher and the server projection. | | `hasManagedSettingsDefinitions(policyDefinitions)` | Cheap short-circuiting existence check (does *any* policy declare a managed key?) — used to decide whether the native MDM watcher is needed at all, without building the full aggregate. | | `projectManagedSettings(values, definitions, onWarn?)` | Keeps only declared keys whose runtime value **matches the declared type**. Undeclared keys and type mismatches are **dropped (validated, never coerced)**, with an optional warning. | -| `selectManagedSettings(server, nativeMdm, file)` | Picks the single authoritative channel by precedence (server → native MDM → file); never merges. **The extension point when adding a new channel** — extend the `ManagedSettingsSource` union and this function together. | +| `selectManagedSettings(nativeMdm, server, file)` | Picks the single authoritative channel by precedence (native MDM → server → file); never merges. **The extension point when adding a new channel** — extend the `ManagedSettingsSource` union and this function together. | | `managedSettingValue(key)` | Builds the standard pass-through `value` callback `policyData => policyData.managedSettings?.[key]`. Use for the common "lock to the managed value, else fall through" case (see [Declaring a managed setting](#declaring-a-managed-setting-on-a-policy)). | ### Normalization: the structured-key descriptor table diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 49404274782..0d433f21da7 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13,7 +13,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.66-2", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -2952,9 +2952,9 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.65.tgz", - "integrity": "sha512-J1XvLuOiVpiAi/E1MBICBymszCgdGLnZxokosXzGcmcjEVZd+QSDoW/kPRHq6oEyBT9SDASPcjCEZ9Q0rLJllg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66-2.tgz", + "integrity": "sha512-nAhhtfjpryklyombieuu18NK2g+BmEk4/8qvXVj8k+w/63tiVpLxFh865Vf6NQiVh/S7hbjMghTbrptsspYg2w==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -2963,20 +2963,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.65", - "@github/copilot-darwin-x64": "1.0.65", - "@github/copilot-linux-arm64": "1.0.65", - "@github/copilot-linux-x64": "1.0.65", - "@github/copilot-linuxmusl-arm64": "1.0.65", - "@github/copilot-linuxmusl-x64": "1.0.65", - "@github/copilot-win32-arm64": "1.0.65", - "@github/copilot-win32-x64": "1.0.65" + "@github/copilot-darwin-arm64": "1.0.66-2", + "@github/copilot-darwin-x64": "1.0.66-2", + "@github/copilot-linux-arm64": "1.0.66-2", + "@github/copilot-linux-x64": "1.0.66-2", + "@github/copilot-linuxmusl-arm64": "1.0.66-2", + "@github/copilot-linuxmusl-x64": "1.0.66-2", + "@github/copilot-win32-arm64": "1.0.66-2", + "@github/copilot-win32-x64": "1.0.66-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.65.tgz", - "integrity": "sha512-NFc4xIstZNiIuAYkurQT5DVtbZjBoZ/z6yt/Ffcom7Y5QGjfpN4BFuekv9k+OADRioxxR99NgmhjbuNPWtQhNQ==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66-2.tgz", + "integrity": "sha512-gjLRtAQOdFQUOTm7nYi+zufkGxMlQlTzUyncQ3W4u1+WdGQbx5fWqMg/yd+j1yMN9PEETyF/ZHZqAaFWkEpQww==", "cpu": [ "arm64" ], @@ -2990,9 +2990,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.65.tgz", - "integrity": "sha512-0wtV22KmTa12VbqWRRkgvJcBz/oIbszfcIpyDWGc4MzbCVksajQ3TWVQ6c7Sdzj5RifCaYdkHAX2zuIYXYlLoQ==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66-2.tgz", + "integrity": "sha512-wWWBsVwJtRTXqCK8lVpzwbJd3Tm1F23avf942K+PmsGYiZZYNcS5pt4umQRRj0sHKgO/muuA4eg/tMfGNi5fgA==", "cpu": [ "x64" ], @@ -3006,12 +3006,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.65.tgz", - "integrity": "sha512-dOwdy/YbTXQN/+x2v4ZgiDycdRtWElyHxPuA6ail3yJDt0nagwn8OYAA/diBLPMAJuuBXiOZGvvb9fGRuh7Xgg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66-2.tgz", + "integrity": "sha512-j0hjx77JNFR3ZS8z3flY2j5SfGZMfKigYVFpDlTJM8FhfkMCUJ5IUhsZwSTimhHlxrsXuI31S6g0WsZLmBUe6A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3022,12 +3025,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.65.tgz", - "integrity": "sha512-al/1a/l/GrpHtygTxt7PZspmv0eHBPdAZ5B31J7Hv/GRdVZM4STCC9dCIOSUFsOX2fhaKD8yLfz4HureSYs23g==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66-2.tgz", + "integrity": "sha512-vWaNbh4WdwkiI40Thcfbwi8tZFKo06r+Dm9Zfb8uY4wAz3X5PaGeSq+8XrNoV3uaRWltI0ncSIrq5tSOyDtRPg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3038,12 +3044,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.65.tgz", - "integrity": "sha512-xccQeJSR45xyoaL7J5mZjtU++dmte+ZCDQkIlrpTn2yuMl2LWriBvorQ1P2MwVnXmIiW/GHi93B+lNtsybA9yw==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66-2.tgz", + "integrity": "sha512-LbWy5NlWasBeV/i+Xol+8dW7kbAQr6MF46apbseRNHYkhwyF/417WtLfirP8O2hPuqyU72q/HAQziFXkz14pIQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3054,12 +3063,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.65.tgz", - "integrity": "sha512-RHPVUaqjSrhKHQ2EpfGKWErnV+R5elGIZaHXPKO10zpSaQD9b/C9u6nLigZnBuT/8sCaJpVrazPMwOYvYA62aw==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66-2.tgz", + "integrity": "sha512-djOu52fGIU7eUhQdUS0K5xB2eFdi8LTTbxvphHWlrN1AD1BdZ+VX9Pk2avt6yCfW+Hh0loh2pNsCbTfNyxvULA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3070,9 +3082,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.65.tgz", - "integrity": "sha512-/vSE/t9Wm3eFSWpxlKyn/oL8OAVOB0yFO7ECxhgbtiqNrBd1tgpYh1k7IXBIWa/saxlV1+de6DEmPuQfx3Z0bg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66-2.tgz", + "integrity": "sha512-YQu01atiwoz8XfrHKqvI1xNjnc2IIIxgJDkQ6PxwrWPZ4IO320izwlXbW2ZaOz9yDgjWNis6EJ4Ryz8K+mM6kg==", "cpu": [ "arm64" ], @@ -3086,9 +3098,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.65.tgz", - "integrity": "sha512-wjVWXepET+SpFg8z8V43ZiTy6X1OerCb7yu3QZKNNJ3zY9z20goihPXQCDWkiJpGzszNSgfrsiqUzpUsC9qS0A==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66-2.tgz", + "integrity": "sha512-4/kTs+lKc67f7KEAQ+Gt3sEBFDSEGoUxJujddV/+fS8EAg9uF2g6e3NzS1I4+htyRM4Oq/Z6xfWjGUgQsi9rfw==", "cpu": [ "x64" ], diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 2fb215472cd..e205e8d4b98 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4062,19 +4062,6 @@ "clear-both" ] }, - "github.copilot.chat.responsesApiReasoningSummary": { - "type": "string", - "default": "detailed", - "markdownDescription": "%github.copilot.config.responsesApiReasoningSummary%", - "tags": [ - "experimental", - "onExp" - ], - "enum": [ - "off", - "detailed" - ] - }, "github.copilot.chat.responsesApiContextManagement.enabled": { "type": "boolean", "default": false, @@ -4364,6 +4351,24 @@ "experimental", "onExp" ] + }, + "github.copilot.chat.tools.grepSearch.defaultMaxResults": { + "type": "number", + "default": 20, + "markdownDescription": "%github.copilot.chat.tools.grepSearch.defaultMaxResults%", + "tags": [ + "experimental", + "onExp" + ] + }, + "github.copilot.chat.tools.grepSearch.maxResultsCap": { + "type": "number", + "default": 200, + "markdownDescription": "%github.copilot.chat.tools.grepSearch.maxResultsCap%", + "tags": [ + "experimental", + "onExp" + ] } } }, @@ -7194,7 +7199,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.66-2", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 0a4569e6442..59e168889c1 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -279,6 +279,8 @@ "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'tag'.", + "github.copilot.chat.tools.grepSearch.defaultMaxResults": "The default maximum number of results to return from the grep search tool. The default is 20.", + "github.copilot.chat.tools.grepSearch.maxResultsCap": "The maximum number of results that can be returned from the grep search tool. The default is 200.", "copilot.tools.errors.description": "Check errors for a particular file", "copilot.tools.applyPatch.description": "Edit text files in the workspace", "copilot.tools.findTestFiles.description": "For a source code file, find the file that contains the tests. For a test file, find the file that contains the code under test", @@ -344,7 +346,6 @@ "github.copilot.config.anthropic.promptCaching.extendedTtlMessages": "Also extend the 1 hour prompt cache TTL to message-level breakpoints. Requires `chat.anthropic.promptCaching.extendedTtl` to be enabled; has no effect on its own.\n\n**Note**: This is an experimental feature.", "github.copilot.config.modelCapabilityOverrides": "Per-model capability overrides keyed by model id, intended for evaluating preview and tenanted models against an existing model's capability profile. For each model id, declare an aliased `family`. Setting `family` to a known production family (e.g. `\"claude-opus-4.7\"`) makes the model receive that family's full capability profile — Anthropic family detection, latest Opus prompt, multi-replace tools, tool search, context editing, extended cache TTL — without a code change.\n\n**Note**: This is an advanced setting for evaluation use; it is not intended for regular end-user configuration.", "github.copilot.config.useResponsesApi": "Use the Responses API instead of the Chat Completions API when supported. Enables reasoning and reasoning summaries.\n\n**Note**: This is an experimental feature that is not yet activated for all users.\n\n**Important**: For Custom Endpoint models, the API type is independent of this setting and is determined per-model via the `apiType` property, or inferred from the `url` path when omitted.", - "github.copilot.config.responsesApiReasoningSummary": "Sets the reasoning summary style used for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApiContextManagement.enabled": "Enables context management for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApi.promptCacheKey.enabled": "Enables prompt cache key being set for the Responses API.", "github.copilot.config.responsesApi.persistentCoT.enabled": "Enables persistent chain of thought for supported Responses API models.", diff --git a/extensions/copilot/script/setup/getEnv.mts b/extensions/copilot/script/setup/getEnv.mts index 0cd82c9a737..dd04e6e7d2c 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -57,8 +57,7 @@ async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { secrets['HMAC_SECRET'] = await fetchSecret(keyVaultClient, 'hmac-secret'); if (!process.stdin.isTTY) { // only in automation - secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth'); - secrets['VSCODE_COPILOT_CHAT_TOKEN'] = await fetchSecret(keyVaultClient, 'copilot-token'); + secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth-pipeline-token'); secrets['BLACKBIRD_EMBEDDINGS_KEY'] = await fetchSecret(keyVaultClient, 'vsc-aoai-key'); secrets['BLACKBIRD_REDIS_CACHE_KEY'] = await fetchSecret(keyVaultClient, 'blackbird-redis-cache-key'); diff --git a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts index b9b9eed5184..7fffbbc9f9d 100644 --- a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts +++ b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts @@ -246,9 +246,19 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { describe('Responses API mode (useResponsesApi = true)', () => { it('should preserve reasoning object when thinking is supported', () => { - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); + const modelWithReasoningEffort = { + ...modelMetadata, + capabilities: { + ...modelMetadata.capabilities, + supports: { + ...modelMetadata.capabilities.supports, + reasoning_effort: ['low', 'medium', 'high'] + } + } + }; + const endpoint = instaService.createInstance(OpenAIEndpoint, - modelMetadata, + modelWithReasoningEffort, 'test-api-key', 'https://api.openai.com/v1/chat/completions'); @@ -275,7 +285,6 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { } }; - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); const endpoint = instaService.createInstance(OpenAIEndpoint, modelWithoutThinking, 'test-api-key', diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts index 603c74457c4..102e9bedda5 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts @@ -610,7 +610,7 @@ export function buildChatHistoryFromEvents(sessionId: string, modelId: string | } }); ((event.data.attachments || [])) - .filter(attachment => attachment.type === 'selection' || attachment.type === 'github_reference' || attachment.type === 'blob' || attachment.type === 'extension_context' ? true : !isInstructionAttachmentPath(attachment.path)) + .filter(attachment => attachment.type === 'selection' || attachment.type === 'github_reference' || attachment.type === 'blob' || attachment.type === 'github_actions_job' || attachment.type === 'github_url' || attachment.type === 'github_tree_comparison' || attachment.type === 'github_release' || attachment.type === 'github_repository' || attachment.type === 'github_file_diff' || attachment.type === 'github_commit' || attachment.type === 'github_file' || attachment.type === 'extension_context' ? true : !isInstructionAttachmentPath(attachment.path)) .forEach(attachment => { if (attachment.type === 'github_reference') { return; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 4e94515ba26..ca6ed26a50c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -2779,14 +2779,58 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private _renderAttachments(attachments: Attachment[]): string[] { const lines: string[] = []; for (const attachment of attachments) { - if (attachment.type === 'github_reference') { - lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`); - } else if (attachment.type === 'blob') { - lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`); - } else if (attachment.type === 'extension_context') { - lines.push(`- ${attachment.title ?? 'extension_context'} (${attachment.type}, ${attachment.extensionId})`); - } else { - lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`); + switch (attachment.type) { + case 'github_reference': { + lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`); + break; + } + case 'github_actions_job': { + lines.push(`- ${attachment.jobName}: (${attachment.jobId}, ${attachment.type})`); + break; + } + case 'github_commit': { + lines.push(`- ${attachment.message}: (${attachment.oid}, ${attachment.type})`); + break; + } + case 'github_file': { + lines.push(`- ${attachment.path}: (${attachment.ref}, ${attachment.type})`); + break; + } + case 'github_file_diff': { + lines.push(`- ${attachment.url}: (${attachment.type})`); + break; + } + case 'github_release': { + lines.push(`- ${attachment.name}: (${attachment.tagName}, ${attachment.type})`); + break; + } + case 'github_repository': { + lines.push(`- ${attachment.repo.name}: (${attachment.url}, ${attachment.type})`); + break; + } + case 'github_tree_comparison': { + lines.push(`- ${attachment.head}: (${attachment.base}, ${attachment.type})`); + break; + } + case 'github_url': { + lines.push(`- ${attachment.url}: (${attachment.type})`); + break; + } + case 'github_snippet': { + lines.push(`- ${attachment.path}: (${attachment.type})`); + break; + } + case 'blob': { + lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`); + break; + } + case 'extension_context': { + lines.push(`- ${attachment.title ?? 'extension_context'} (${attachment.type}, ${attachment.extensionId})`); + break; + } + default: { + lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`); + } } } return lines; 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 5040f58cde7..2f6403a02de 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 @@ -176,6 +176,7 @@ suite('LanguageModelAccess model info', () => { testingServiceCollection.define(IAutomodeService, { _serviceBrand: undefined, resolveAutoModeEndpoint: async () => endpoint, + consumeLastRoutingDecision: () => undefined, invalidateRouterCache: () => { }, } as unknown as IAutomodeService); testingServiceCollection.define(IEndpointProvider, { diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 5a5d51eed70..b4446bb114a 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -41,7 +41,7 @@ import { Iterable } from '../../../util/vs/base/common/iterator'; import { DisposableMap, DisposableStore } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseProgressPart2 } from '../../../vscodeTypes'; +import { ChatResponseAutoModeResolutionPart, ChatResponseProgressPart2 } from '../../../vscodeTypes'; import { ICommandService } from '../../commands/node/commandService'; import { Intent } from '../../common/constants'; import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection'; @@ -464,6 +464,12 @@ export class AgentIntent extends EditCodeIntent { return this.handleSummarizeCommand(conversation, request, stream, token); } + // Report auto-mode routing decision if one was made during endpoint resolution + const routingDecision = this._automodeService.consumeLastRoutingDecision(); + if (routingDecision) { + stream.push(new ChatResponseAutoModeResolutionPart(routingDecision.resolvedModel, routingDecision.resolvedModelName, routingDecision.predictedLabel, routingDecision.confidence)); + } + try { return await super.handleRequest(conversation, request, stream, token, documentContext, agentName, location, chatTelemetry, yieldRequested); } finally { diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts index 4d4d70cef03..6de88e0fe98 100644 --- a/extensions/copilot/src/extension/test/node/services.ts +++ b/extensions/copilot/src/extension/test/node/services.ts @@ -205,5 +205,9 @@ class NullAutomodeService implements IAutomodeService { throw new Error('Not implemented'); } + consumeLastRoutingDecision(): undefined { + return undefined; + } + invalidateRouterCache(): void { } } diff --git a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx index ff0a24a6d3e..55fe7200bb4 100644 --- a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx +++ b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx @@ -41,8 +41,6 @@ interface IFindTextInFilesToolParams { includeIgnoredFiles?: boolean; } -const MaxResultsCap = 200; - interface FileMatch { path: string; matches: vscode.TextSearchMatch2[]; @@ -96,11 +94,13 @@ export class FindTextInFilesTool implements ICopilotTool MaxResultsCap; - const maxResults = Math.min(options.input.maxResults ?? 20, MaxResultsCap); + const askedForTooManyResults = options.input.maxResults && options.input.maxResults > maxResultsCap; + const maxResults = Math.min(options.input.maxResults ?? defaultMaxResults, maxResultsCap); const isRegExp = options.input.isRegexp ?? true; const queryIsValidRegex = this.isValidRegex(options.input.query); const includeIgnoredFiles = options.input.includeIgnoredFiles ?? false; @@ -154,14 +154,14 @@ Then if you want to include those files you can call the tool again by setting " if (useGrepStyle) { return this.renderGrepStyle(results, options, maxResults, globResult, isRegExp, noMatchInstructions, token); } else { - return this.renderTagStyle(results, options, maxResults, globResult, askedForTooManyResults, isRegExp, noMatchInstructions, token); + return this.renderTagStyle(results, options, maxResults, maxResultsCap, globResult, askedForTooManyResults, isRegExp, noMatchInstructions, token); } } - private async renderTagStyle(results: vscode.TextSearchResult2[], options: vscode.LanguageModelToolInvocationOptions, maxResults: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise { + private async renderTagStyle(results: vscode.TextSearchResult2[], options: vscode.LanguageModelToolInvocationOptions, maxResults: number, maxResultsCap: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise { const prompt = await renderPromptElementJSON(this.instantiationService, FindTextInFilesResult, - { textResults: results, maxResults, askedForTooManyResults: Boolean(askedForTooManyResults), noMatchInstructions }, + { textResults: results, maxResults, maxResultsCap, askedForTooManyResults: Boolean(askedForTooManyResults), noMatchInstructions }, options.tokenizationOptions, token); @@ -306,7 +306,7 @@ Then if you want to include those files you can call the tool again by setting " return result; } - private async sendSearchToolTelemetry(options: vscode.LanguageModelToolInvocationOptions, globResult: InputGlobResult | undefined, outputFormat: string): Promise { + private async sendSearchToolTelemetry(options: vscode.LanguageModelToolInvocationOptions, globResult: InputGlobResult | undefined, outputFormat: string, defaultMaxResults: number, maxResultsCap: number): Promise { const model = options.model && (await this.endpointProvider.getChatEndpoint(options.model)).model; const isMultiRoot = this.workspaceService.getWorkspaceFolders().length > 1; const includePattern = options.input.includePattern; @@ -320,7 +320,9 @@ Then if you want to include those files you can call the tool again by setting " "patternScopedToFolder": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the includePattern was resolved to a specific workspace folder" }, "patternStartsWithFolderPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the raw includePattern starts with a workspace folder absolute path" }, "patternContainsFolderPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the raw includePattern contains a workspace folder absolute path anywhere" }, - "outputFormat": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The output format of the search results" } + "outputFormat": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The output format of the search results" }, + "defaultMaxResults": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The default maximum number of results" }, + "maxResultsCap": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The maximum number of results that can be returned" } } */ this.telemetryService.sendMSFTTelemetryEvent('findTextInFilesToolInvoked', { @@ -331,6 +333,9 @@ Then if you want to include those files you can call the tool again by setting " patternStartsWithFolderPath: String(!!includePattern && isAbsolute(includePattern) && !!this.workspaceService.getWorkspaceFolder(URI.file(includePattern))), patternContainsFolderPath: String(patternContainsWorkspaceFolderPath(includePattern, this.workspaceService)), outputFormat: outputFormat + },{ + defaultMaxResults, + maxResultsCap }); } @@ -451,12 +456,23 @@ Then if you want to include those files you can call the tool again by setting " const expFlag = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchOutputFormat, this.experimentationService); return expFlag === 'grep' ? 'grep' : 'tag'; } + + private getDefaultMaxResults(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchDefaultMaxResults, this.experimentationService); + return Number.isFinite(result) ? Math.floor(result) : 20; + } + + private getMaxResultsCap(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchMaxResultsCap, this.experimentationService); + return Number.isFinite(result) ? Math.floor(result) : 200; + } } ToolRegistry.registerTool(FindTextInFilesTool); export interface FindTextInFilesResultProps extends BasePromptElementProps { textResults: vscode.TextSearchResult2[]; maxResults: number; + maxResultsCap: number; askedForTooManyResults?: boolean; noMatchInstructions?: string; } @@ -479,7 +495,7 @@ export class FindTextInFilesResult extends PromptElement this.props.maxResults ? ` (more results are available)` : ''; - const maxResultsTooLargeText = this.props.askedForTooManyResults ? ` (maxResults capped at ${MaxResultsCap})` : ''; + const maxResultsTooLargeText = this.props.askedForTooManyResults ? ` (maxResults capped at ${this.props.maxResultsCap})` : ''; return <> {{numResultsText}{maxResultsText}{maxResultsTooLargeText}} {textMatches.flatMap(result => { diff --git a/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx b/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx index 764f770aa02..0e8255bd90e 100644 --- a/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx @@ -30,7 +30,7 @@ suite('FindTextInFilesResult', () => { const clz = class extends PromptElement { render() { return - + ; } }; diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 405c7ca13ee..8ddfd19d130 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -904,15 +904,6 @@ export namespace ConfigKey { export const InlineEditsJointCompletionsProviderTriggerChangeStrategy = defineTeamInternalSetting('chat.advanced.inlineEdits.jointCompletionsProvider.triggerChangeStrategy', ConfigType.ExperimentBased, JointCompletionsProviderTriggerChangeStrategy.NoTriggerOnCompletionsRequestInFlight); export const InstantApplyModelName = defineTeamInternalSetting('chat.advanced.instantApply.modelName', ConfigType.ExperimentBased, CHAT_MODEL.GPT4OPROXY); export const VerifyTextDocumentChanges = defineTeamInternalSetting('chat.advanced.inlineEdits.verifyTextDocumentChanges', ConfigType.ExperimentBased, false); - export const UseAutoModeRouting = defineTeamInternalSetting('chat.advanced.useAutoModeRouter', ConfigType.ExperimentBased, false); - /** Controls which `routing_method` value is sent to the auto-intent-service per request - * when `UseAutoModeRouting` is enabled. - * '' (empty/default) = omit `routing_method` and use the server default. - * 'binary' = binary classifier v1. - * 'hydra' = HYDRA multi-head capability matching. - * For experiments, this setting selects the routing method only when router usage is enabled; - * it does not by itself determine whether the router is called. */ - export const AutoModeRoutingMethod = defineTeamInternalSetting('chat.advanced.autoModeRoutingMethod', ConfigType.ExperimentBased, '', undefined, undefined, { experimentName: 'copilotchat.autoModeRoutingMethod' }); /** Inline Completions */ export const InlineCompletionsDefaultDiagnosticsOptions = defineTeamInternalSetting('chat.advanced.inlineCompletions.defaultDiagnosticsOptionsString', ConfigType.ExperimentBased, undefined); @@ -973,8 +964,6 @@ export namespace ConfigKey { export const UseAnthropicMessagesApi = defineSetting('chat.anthropic.useMessagesApi', ConfigType.ExperimentBased, true); /** Context editing mode for Anthropic Messages API. 'off' disables context editing. */ export const AnthropicContextEditingMode = defineSetting<'off' | 'clear-thinking' | 'clear-tooluse' | 'clear-both'>('chat.anthropic.contextEditing.mode', ConfigType.ExperimentBased, 'off'); - /** Configure reasoning summary style sent to Responses API */ - export const ResponsesApiReasoningSummary = defineSetting<'off' | 'detailed'>('chat.responsesApiReasoningSummary', ConfigType.ExperimentBased, 'detailed'); /** Enable context_management sent to Responses API */ export const ResponsesApiContextManagementEnabled = defineSetting('chat.responsesApiContextManagement.enabled', ConfigType.ExperimentBased, false); /** Enable client-side prompt_cache_key (conversationId:modelFamily) sent to Responses API */ @@ -1106,6 +1095,8 @@ export namespace ConfigKey { /** grep_search configs */ export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'tag'); + export const GrepSearchDefaultMaxResults = defineSetting('chat.tools.grepSearch.defaultMaxResults', ConfigType.ExperimentBased, 20); + export const GrepSearchMaxResultsCap = defineSetting('chat.tools.grepSearch.maxResultsCap', ConfigType.ExperimentBased, 200); } export function getAllConfigKeys(): string[] { diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 94bdd303809..2171ee2bc98 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -11,7 +11,6 @@ import { Disposable, DisposableMap } from '../../../util/vs/base/common/lifecycl import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../../vscodeTypes'; import { IAuthenticationService } from '../../authentication/common/authentication'; -import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { IEnvService } from '../../env/common/envService'; import { getImageTelemetryEventMeasurements, getImageTelemetryMeasurementsFromReferences, type ImageTelemetryMeasurements } from '../../image/common/imageTelemetry'; import { ILogService } from '../../log/common/logService'; @@ -104,6 +103,13 @@ class AutoModeTokenBank extends Disposable { } } +export interface AutoModeRoutingDecision { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: 'needs_reasoning' | 'no_reasoning' | 'fallback'; + confidence: number; +} + export const IAutomodeService = createServiceIdentifier('IAutomodeService'); export interface IAutomodeService { @@ -111,6 +117,13 @@ export interface IAutomodeService { resolveAutoModeEndpoint(chatRequest: ChatRequest | undefined, knownEndpoints: IChatEndpoint[]): Promise; + /** + * Returns the routing decision from the last call to {@link resolveAutoModeEndpoint}, + * or `undefined` if the router was not used (e.g. skipped, fallback, or non-auto model). + * Cleared after reading. + */ + consumeLastRoutingDecision(): AutoModeRoutingDecision | undefined; + /** * Marks the router cache for this conversation as needing re-evaluation. * The next call to {@link resolveAutoModeEndpoint} will re-run the router @@ -124,6 +137,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { private readonly _autoModelCache: Map = new Map(); private _reserveTokens: DisposableMap = new DisposableMap(); private readonly _routerDecisionFetcher: RouterDecisionFetcher; + private _lastRoutingDecision: AutoModeRoutingDecision | undefined; constructor( @ICAPIClientService private readonly _capiClientService: ICAPIClientService, @@ -131,7 +145,6 @@ export class AutomodeService extends Disposable implements IAutomodeService { @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IExperimentationService private readonly _expService: IExperimentationService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvService private readonly _envService: IEnvService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IRequestLogger private readonly _requestLogger: IRequestLogger, @@ -161,6 +174,12 @@ export class AutomodeService extends Disposable implements IAutomodeService { super.dispose(); } + consumeLastRoutingDecision(): AutoModeRoutingDecision | undefined { + const decision = this._lastRoutingDecision; + this._lastRoutingDecision = undefined; + return decision; + } + /** * Resolve an auto mode endpoint * Optionally uses a router model to select the best endpoint based on the prompt. @@ -179,6 +198,10 @@ export class AutomodeService extends Disposable implements IAutomodeService { throw new Error('No auto mode endpoints provided.'); } + // Clear any previous routing decision upfront so stale data cannot + // leak to a consumer if this call takes a non-router path. + this._lastRoutingDecision = undefined; + const conversationId = chatRequest?.sessionResource?.toString() ?? chatRequest?.sessionId ?? 'unknown'; const entry = this._autoModelCache.get(conversationId); const tokenBank = this._acquireTokenBank(entry, chatRequest?.location, conversationId); @@ -239,6 +262,15 @@ export class AutomodeService extends Disposable implements IAutomodeService { selectedModel = this._applyVisionFallback(chatRequest, selectedModel, token.available_models, knownEndpoints); + // Store routing decision for the UI to consume (update resolved model to the final one after all overrides) + if (routerResult.routingDecision) { + this._lastRoutingDecision = { + ...routerResult.routingDecision, + resolvedModel: selectedModel.model, + resolvedModelName: selectedModel.name, + }; + } + // Emit the final model selection alongside the router's recommendation // so analysts can detect overrides without fragile telemetry joins if (!skipRouter && routerResult.candidateModel) { @@ -315,7 +347,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { token: AutoModeAPIResponse, knownEndpoints: IChatEndpoint[], imageTelemetryEventMeasurements: Partial, - ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string }> { + ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string; routingDecision?: AutoModeRoutingDecision }> { const prompt = chatRequest?.prompt?.trim(); const lastRoutedPrompt = entry?.lastRoutedPrompt ?? prompt; @@ -340,7 +372,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { previous_model: entry?.endpoint?.model, turn_number: (entry?.turnCount ?? 0) + 1, }; - const routingMethod = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AutoModeRoutingMethod, this._expService) || undefined; + const routingMethod = 'hydra'; // Filter available_models to only those the client can actually serve. // The AutoModels API and Models API are separate CAPI calls that can be @@ -387,7 +419,17 @@ export class AutomodeService extends Disposable implements IAutomodeService { if (result.sticky_override) { this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${result.candidate_models[0]}, actual_model=${selectedModel.model}`); } - return { selectedModel, lastRoutedPrompt: prompt, candidateModel: result.candidate_models[0] }; + return { + selectedModel, + lastRoutedPrompt: prompt, + candidateModel: result.candidate_models[0], + routingDecision: { + resolvedModel: selectedModel.model, + resolvedModelName: selectedModel.name, + predictedLabel: result.predicted_label, + confidence: result.confidence, + }, + }; } catch (e) { const isTimeout = isAbortError(e); let fallbackReason: string; @@ -440,7 +482,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { private _isRouterEnabled(chatRequest: ChatRequest | undefined): boolean { const isPanelChat = !chatRequest?.location || chatRequest?.location === ChatLocation.Panel; - return isPanelChat && this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.UseAutoModeRouting, this._expService); + return isPanelChat; } /** diff --git a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts index 46912f0693c..601295b10a8 100644 --- a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts @@ -175,7 +175,10 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._copilotUtilityModel; if (!resolvedModel || !isChatModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)')); + // If the model fetch itself failed (e.g. an expired token returning HTTP 401), surface that + // underlying error rather than the misleading "no fallback model" message, which only makes + // sense when the fetch succeeded but the server genuinely marked no chat fallback. + throw this._lastFetchError ?? new Error(await this._getErrorMessage('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)')); } return resolvedModel; } @@ -184,7 +187,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._familyMap.get(family)?.[0]; if (!resolvedModel || !isChatModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage(`Unable to resolve chat model with CAPI family selection: ${family}`)); + throw this._lastFetchError ?? new Error(await this._getErrorMessage(`Unable to resolve chat model with CAPI family selection: ${family}`)); } return resolvedModel; } @@ -220,7 +223,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._familyMap.get(family)?.[0]; if (!resolvedModel || !isEmbeddingModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage(`Unable to resolve embeddings model with family selection: ${family}`)); + throw this._lastFetchError ?? new Error(await this._getErrorMessage(`Unable to resolve embeddings model with family selection: ${family}`)); } return resolvedModel; } diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index fba5bbff3f7..8192d578854 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -155,14 +155,11 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: body.truncation = configService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation) ? 'auto' : 'disabled'; - const thinkingExplicitlyDisabled = options.modelCapabilities?.enableThinking === false; - const summaryConfig = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiReasoningSummary, expService); - const shouldDisableReasoningSummary = endpoint.family === 'gpt-5.3-codex-spark-preview' || thinkingExplicitlyDisabled; const effortFromSetting = configService.getConfig(ConfigKey.Advanced.ReasoningEffortOverride); const effort = endpoint.supportsReasoningEffort?.length ? (effortFromSetting || options.modelCapabilities?.reasoningEffort || 'medium') : undefined; - const summary = summaryConfig === 'off' || shouldDisableReasoningSummary ? undefined : summaryConfig; + const summary: string | undefined = undefined; const persistentCoTEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, expService) && (isGpt54(endpoint) || isGpt55(endpoint) || isHiddenModelM(endpoint)); if (effort || summary || persistentCoTEnabled) { diff --git a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts index 436ce8b5038..f205f027b4d 100644 --- a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts @@ -29,6 +29,7 @@ export interface RouterDecisionResponse { hydra_scores?: Record; chosen_model?: string; chosen_shortfall?: number; + reasoning_bucket?: string; } export interface RoutingContextSignals { diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index fb5e98e155b..ee6b1f453c9 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -9,9 +9,6 @@ import type { ChatRequest } from 'vscode'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../../../vscodeTypes'; import { IAuthenticationService } from '../../../authentication/common/authentication'; -import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; -import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; -import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; import { NullEnvService } from '../../../env/common/nullEnvService'; import { ILogService } from '../../../log/common/logService'; import { IChatEndpoint } from '../../../networking/common/networking'; @@ -58,7 +55,6 @@ describe('AutomodeService', () => { let mockLogService: ILogService; let mockInstantiationService: IInstantiationService; let mockExpService: IExperimentationService; - let configurationService: IConfigurationService; let mockChatEndpoint: IChatEndpoint; let envService: NullEnvService; let mockTelemetryService: ITelemetryService & { sendEnhancedGHTelemetryEvent: ReturnType; sendMSFTTelemetryEvent: ReturnType }; @@ -87,7 +83,6 @@ describe('AutomodeService', () => { mockLogService, mockInstantiationService, mockExpService, - configurationService, envService, mockTelemetryService, new NullRequestLogger() @@ -105,10 +100,7 @@ describe('AutomodeService', () => { } function enableRouter(): void { - (configurationService as InMemoryConfigurationService).setConfig( - ConfigKey.TeamInternal.UseAutoModeRouting, - true - ); + // Router is now always enabled for panel chat — no config key needed. } beforeEach(() => { @@ -145,7 +137,6 @@ describe('AutomodeService', () => { mockExpService = new NullExperimentationService(); - configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); envService = new NullEnvService(); mockTelemetryService = { sendTelemetryEvent: vi.fn(), @@ -286,24 +277,6 @@ describe('AutomodeService', () => { expect(parsed.previous_model).toBeUndefined(); }); - it('should not use router when routing is not enabled', async () => { - // Routing not enabled via UseAutoModeRouting config - automodeService = createService(); - - const chatRequest: Partial = { - location: ChatLocation.Panel, - prompt: 'test prompt' - }; - - await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [mockChatEndpoint]); - - // Verify that router API was NOT called (exp / config disabled) - expect(mockCAPIClientService.makeRequest).not.toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ type: RequestType.ModelRouter }) - ); - }); - it('should not use router for terminal chat', async () => { enableRouter(); diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index c8bd81d3882..6aff5e0d250 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -378,7 +378,7 @@ describe('createResponsesRequestBody', () => { const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), endpoint.model, endpoint)); - expect(body.reasoning).toEqual({ effort: 'medium', summary: 'detailed', context: 'all_turns' }); + expect(body.reasoning).toEqual({ effort: 'medium', context: 'all_turns' }); accessor.dispose(); services.dispose(); diff --git a/extensions/copilot/src/util/common/test/shims/chatTypes.ts b/extensions/copilot/src/util/common/test/shims/chatTypes.ts index e911a840d28..cdbce3da031 100644 --- a/extensions/copilot/src/util/common/test/shims/chatTypes.ts +++ b/extensions/copilot/src/util/common/test/shims/chatTypes.ts @@ -186,6 +186,20 @@ export class ChatResponsePullRequestPart { } +export class ChatResponseAutoModeResolutionPart { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: string; + confidence: number; + constructor(resolvedModel: string, resolvedModelName: string, predictedLabel: string, confidence: number) { + this.resolvedModel = resolvedModel; + this.resolvedModelName = resolvedModelName; + this.predictedLabel = predictedLabel; + this.confidence = confidence; + } +} + + export class ChatResponseCodeCitationPart { value: vscode.Uri; license: string; diff --git a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts index 35a1d656b56..a2aa56f6953 100644 --- a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts +++ b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts @@ -18,7 +18,7 @@ import { SnippetString } from '../../../vs/workbench/api/common/extHostTypes/sni import { SnippetTextEdit } from '../../../vs/workbench/api/common/extHostTypes/snippetTextEdit'; import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/extHostTypes/symbolInformation'; import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit'; -import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; +import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseAutoModeResolutionPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing'; import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; import { t } from './l10n'; @@ -106,6 +106,7 @@ const shim: typeof vscodeTypes = { TerminalShellExecutionCommandLineConfidence, ChatRequestEditedFileEventKind, ChatResponsePullRequestPart, + ChatResponseAutoModeResolutionPart, LanguageModelTextPart2, LanguageModelDataPart2, LanguageModelThinkingPart, diff --git a/extensions/copilot/src/vscodeTypes.ts b/extensions/copilot/src/vscodeTypes.ts index 6b9519e05a6..a931ec06d7c 100644 --- a/extensions/copilot/src/vscodeTypes.ts +++ b/extensions/copilot/src/vscodeTypes.ts @@ -42,6 +42,7 @@ export import ChatResponseMovePart = vscode.ChatResponseMovePart; export import ChatResponseExtensionsPart = vscode.ChatResponseExtensionsPart; export import ChatResponseExternalEditPart = vscode.ChatResponseExternalEditPart; export import ChatResponsePullRequestPart = vscode.ChatResponsePullRequestPart; +export import ChatResponseAutoModeResolutionPart = vscode.ChatResponseAutoModeResolutionPart; export import ChatResponseMarkdownWithVulnerabilitiesPart = vscode.ChatResponseMarkdownWithVulnerabilitiesPart; export import ChatResponseCodeblockUriPart = vscode.ChatResponseCodeblockUriPart; export import ChatResponseTextEditPart = vscode.ChatResponseTextEditPart; diff --git a/package-lock.json b/package-lock.json index 0ddd1782026..5c19a8154a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.66-2", + "@github/copilot-sdk": "^1.0.5-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1107,9 +1107,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.65.tgz", - "integrity": "sha512-J1XvLuOiVpiAi/E1MBICBymszCgdGLnZxokosXzGcmcjEVZd+QSDoW/kPRHq6oEyBT9SDASPcjCEZ9Q0rLJllg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66-2.tgz", + "integrity": "sha512-nAhhtfjpryklyombieuu18NK2g+BmEk4/8qvXVj8k+w/63tiVpLxFh865Vf6NQiVh/S7hbjMghTbrptsspYg2w==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -1118,20 +1118,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.65", - "@github/copilot-darwin-x64": "1.0.65", - "@github/copilot-linux-arm64": "1.0.65", - "@github/copilot-linux-x64": "1.0.65", - "@github/copilot-linuxmusl-arm64": "1.0.65", - "@github/copilot-linuxmusl-x64": "1.0.65", - "@github/copilot-win32-arm64": "1.0.65", - "@github/copilot-win32-x64": "1.0.65" + "@github/copilot-darwin-arm64": "1.0.66-2", + "@github/copilot-darwin-x64": "1.0.66-2", + "@github/copilot-linux-arm64": "1.0.66-2", + "@github/copilot-linux-x64": "1.0.66-2", + "@github/copilot-linuxmusl-arm64": "1.0.66-2", + "@github/copilot-linuxmusl-x64": "1.0.66-2", + "@github/copilot-win32-arm64": "1.0.66-2", + "@github/copilot-win32-x64": "1.0.66-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.65.tgz", - "integrity": "sha512-NFc4xIstZNiIuAYkurQT5DVtbZjBoZ/z6yt/Ffcom7Y5QGjfpN4BFuekv9k+OADRioxxR99NgmhjbuNPWtQhNQ==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66-2.tgz", + "integrity": "sha512-gjLRtAQOdFQUOTm7nYi+zufkGxMlQlTzUyncQ3W4u1+WdGQbx5fWqMg/yd+j1yMN9PEETyF/ZHZqAaFWkEpQww==", "cpu": [ "arm64" ], @@ -1145,9 +1145,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.65.tgz", - "integrity": "sha512-0wtV22KmTa12VbqWRRkgvJcBz/oIbszfcIpyDWGc4MzbCVksajQ3TWVQ6c7Sdzj5RifCaYdkHAX2zuIYXYlLoQ==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66-2.tgz", + "integrity": "sha512-wWWBsVwJtRTXqCK8lVpzwbJd3Tm1F23avf942K+PmsGYiZZYNcS5pt4umQRRj0sHKgO/muuA4eg/tMfGNi5fgA==", "cpu": [ "x64" ], @@ -1161,12 +1161,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.65.tgz", - "integrity": "sha512-dOwdy/YbTXQN/+x2v4ZgiDycdRtWElyHxPuA6ail3yJDt0nagwn8OYAA/diBLPMAJuuBXiOZGvvb9fGRuh7Xgg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66-2.tgz", + "integrity": "sha512-j0hjx77JNFR3ZS8z3flY2j5SfGZMfKigYVFpDlTJM8FhfkMCUJ5IUhsZwSTimhHlxrsXuI31S6g0WsZLmBUe6A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1177,12 +1180,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.65.tgz", - "integrity": "sha512-al/1a/l/GrpHtygTxt7PZspmv0eHBPdAZ5B31J7Hv/GRdVZM4STCC9dCIOSUFsOX2fhaKD8yLfz4HureSYs23g==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66-2.tgz", + "integrity": "sha512-vWaNbh4WdwkiI40Thcfbwi8tZFKo06r+Dm9Zfb8uY4wAz3X5PaGeSq+8XrNoV3uaRWltI0ncSIrq5tSOyDtRPg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1193,12 +1199,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.65.tgz", - "integrity": "sha512-xccQeJSR45xyoaL7J5mZjtU++dmte+ZCDQkIlrpTn2yuMl2LWriBvorQ1P2MwVnXmIiW/GHi93B+lNtsybA9yw==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66-2.tgz", + "integrity": "sha512-LbWy5NlWasBeV/i+Xol+8dW7kbAQr6MF46apbseRNHYkhwyF/417WtLfirP8O2hPuqyU72q/HAQziFXkz14pIQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1209,12 +1218,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.65.tgz", - "integrity": "sha512-RHPVUaqjSrhKHQ2EpfGKWErnV+R5elGIZaHXPKO10zpSaQD9b/C9u6nLigZnBuT/8sCaJpVrazPMwOYvYA62aw==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66-2.tgz", + "integrity": "sha512-djOu52fGIU7eUhQdUS0K5xB2eFdi8LTTbxvphHWlrN1AD1BdZ+VX9Pk2avt6yCfW+Hh0loh2pNsCbTfNyxvULA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1225,12 +1237,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.4.tgz", - "integrity": "sha512-c6/gpatP7LAuP9stlc2uXXOrB+TJMFSs3Jrzu9FG8lJJYFGmdzbBvGz2UdL1jww84fp6D7cohEZa4UGq1+iuyA==", + "version": "1.0.5-preview.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5-preview.0.tgz", + "integrity": "sha512-XFXTemA4yrHEHJY/t7FPXLYEchKjGeLcNl5l1sWT0YT58qilfNjEQSfXS+t5IrCJH6l43IHufcs8YQ8KZ7gKfQ==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.66-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1248,9 +1260,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.65.tgz", - "integrity": "sha512-/vSE/t9Wm3eFSWpxlKyn/oL8OAVOB0yFO7ECxhgbtiqNrBd1tgpYh1k7IXBIWa/saxlV1+de6DEmPuQfx3Z0bg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66-2.tgz", + "integrity": "sha512-YQu01atiwoz8XfrHKqvI1xNjnc2IIIxgJDkQ6PxwrWPZ4IO320izwlXbW2ZaOz9yDgjWNis6EJ4Ryz8K+mM6kg==", "cpu": [ "arm64" ], @@ -1264,9 +1276,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.65.tgz", - "integrity": "sha512-wjVWXepET+SpFg8z8V43ZiTy6X1OerCb7yu3QZKNNJ3zY9z20goihPXQCDWkiJpGzszNSgfrsiqUzpUsC9qS0A==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66-2.tgz", + "integrity": "sha512-4/kTs+lKc67f7KEAQ+Gt3sEBFDSEGoUxJujddV/+fS8EAg9uF2g6e3NzS1I4+htyRM4Oq/Z6xfWjGUgQsi9rfw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 91f0bee534b..bae6248bc5f 100644 --- a/package.json +++ b/package.json @@ -95,8 +95,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.66-2", + "@github/copilot-sdk": "^1.0.5-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/remote/package-lock.json b/remote/package-lock.json index 2d5ba374269..a7513a9e3f0 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,8 +8,8 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.66-2", + "@github/copilot-sdk": "^1.0.5-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", @@ -60,9 +60,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.65.tgz", - "integrity": "sha512-J1XvLuOiVpiAi/E1MBICBymszCgdGLnZxokosXzGcmcjEVZd+QSDoW/kPRHq6oEyBT9SDASPcjCEZ9Q0rLJllg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66-2.tgz", + "integrity": "sha512-nAhhtfjpryklyombieuu18NK2g+BmEk4/8qvXVj8k+w/63tiVpLxFh865Vf6NQiVh/S7hbjMghTbrptsspYg2w==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -71,20 +71,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.65", - "@github/copilot-darwin-x64": "1.0.65", - "@github/copilot-linux-arm64": "1.0.65", - "@github/copilot-linux-x64": "1.0.65", - "@github/copilot-linuxmusl-arm64": "1.0.65", - "@github/copilot-linuxmusl-x64": "1.0.65", - "@github/copilot-win32-arm64": "1.0.65", - "@github/copilot-win32-x64": "1.0.65" + "@github/copilot-darwin-arm64": "1.0.66-2", + "@github/copilot-darwin-x64": "1.0.66-2", + "@github/copilot-linux-arm64": "1.0.66-2", + "@github/copilot-linux-x64": "1.0.66-2", + "@github/copilot-linuxmusl-arm64": "1.0.66-2", + "@github/copilot-linuxmusl-x64": "1.0.66-2", + "@github/copilot-win32-arm64": "1.0.66-2", + "@github/copilot-win32-x64": "1.0.66-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.65.tgz", - "integrity": "sha512-NFc4xIstZNiIuAYkurQT5DVtbZjBoZ/z6yt/Ffcom7Y5QGjfpN4BFuekv9k+OADRioxxR99NgmhjbuNPWtQhNQ==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66-2.tgz", + "integrity": "sha512-gjLRtAQOdFQUOTm7nYi+zufkGxMlQlTzUyncQ3W4u1+WdGQbx5fWqMg/yd+j1yMN9PEETyF/ZHZqAaFWkEpQww==", "cpu": [ "arm64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.65.tgz", - "integrity": "sha512-0wtV22KmTa12VbqWRRkgvJcBz/oIbszfcIpyDWGc4MzbCVksajQ3TWVQ6c7Sdzj5RifCaYdkHAX2zuIYXYlLoQ==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66-2.tgz", + "integrity": "sha512-wWWBsVwJtRTXqCK8lVpzwbJd3Tm1F23avf942K+PmsGYiZZYNcS5pt4umQRRj0sHKgO/muuA4eg/tMfGNi5fgA==", "cpu": [ "x64" ], @@ -114,12 +114,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.65.tgz", - "integrity": "sha512-dOwdy/YbTXQN/+x2v4ZgiDycdRtWElyHxPuA6ail3yJDt0nagwn8OYAA/diBLPMAJuuBXiOZGvvb9fGRuh7Xgg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66-2.tgz", + "integrity": "sha512-j0hjx77JNFR3ZS8z3flY2j5SfGZMfKigYVFpDlTJM8FhfkMCUJ5IUhsZwSTimhHlxrsXuI31S6g0WsZLmBUe6A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -130,12 +133,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.65.tgz", - "integrity": "sha512-al/1a/l/GrpHtygTxt7PZspmv0eHBPdAZ5B31J7Hv/GRdVZM4STCC9dCIOSUFsOX2fhaKD8yLfz4HureSYs23g==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66-2.tgz", + "integrity": "sha512-vWaNbh4WdwkiI40Thcfbwi8tZFKo06r+Dm9Zfb8uY4wAz3X5PaGeSq+8XrNoV3uaRWltI0ncSIrq5tSOyDtRPg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -146,12 +152,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.65.tgz", - "integrity": "sha512-xccQeJSR45xyoaL7J5mZjtU++dmte+ZCDQkIlrpTn2yuMl2LWriBvorQ1P2MwVnXmIiW/GHi93B+lNtsybA9yw==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66-2.tgz", + "integrity": "sha512-LbWy5NlWasBeV/i+Xol+8dW7kbAQr6MF46apbseRNHYkhwyF/417WtLfirP8O2hPuqyU72q/HAQziFXkz14pIQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -162,12 +171,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.65.tgz", - "integrity": "sha512-RHPVUaqjSrhKHQ2EpfGKWErnV+R5elGIZaHXPKO10zpSaQD9b/C9u6nLigZnBuT/8sCaJpVrazPMwOYvYA62aw==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66-2.tgz", + "integrity": "sha512-djOu52fGIU7eUhQdUS0K5xB2eFdi8LTTbxvphHWlrN1AD1BdZ+VX9Pk2avt6yCfW+Hh0loh2pNsCbTfNyxvULA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -178,12 +190,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.4.tgz", - "integrity": "sha512-c6/gpatP7LAuP9stlc2uXXOrB+TJMFSs3Jrzu9FG8lJJYFGmdzbBvGz2UdL1jww84fp6D7cohEZa4UGq1+iuyA==", + "version": "1.0.5-preview.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5-preview.0.tgz", + "integrity": "sha512-XFXTemA4yrHEHJY/t7FPXLYEchKjGeLcNl5l1sWT0YT58qilfNjEQSfXS+t5IrCJH6l43IHufcs8YQ8KZ7gKfQ==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.66-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -201,9 +213,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.65.tgz", - "integrity": "sha512-/vSE/t9Wm3eFSWpxlKyn/oL8OAVOB0yFO7ECxhgbtiqNrBd1tgpYh1k7IXBIWa/saxlV1+de6DEmPuQfx3Z0bg==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66-2.tgz", + "integrity": "sha512-YQu01atiwoz8XfrHKqvI1xNjnc2IIIxgJDkQ6PxwrWPZ4IO320izwlXbW2ZaOz9yDgjWNis6EJ4Ryz8K+mM6kg==", "cpu": [ "arm64" ], @@ -217,9 +229,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.65.tgz", - "integrity": "sha512-wjVWXepET+SpFg8z8V43ZiTy6X1OerCb7yu3QZKNNJ3zY9z20goihPXQCDWkiJpGzszNSgfrsiqUzpUsC9qS0A==", + "version": "1.0.66-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66-2.tgz", + "integrity": "sha512-4/kTs+lKc67f7KEAQ+Gt3sEBFDSEGoUxJujddV/+fS8EAg9uF2g6e3NzS1I4+htyRM4Oq/Z6xfWjGUgQsi9rfw==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index 6b0764ac427..7627d9f93ee 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.66-2", + "@github/copilot-sdk": "^1.0.5-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", diff --git a/src/vs/base/browser/ui/list/list.ts b/src/vs/base/browser/ui/list/list.ts index 3e7cb017967..0d3c7f995b7 100644 --- a/src/vs/base/browser/ui/list/list.ts +++ b/src/vs/base/browser/ui/list/list.ts @@ -151,6 +151,10 @@ export abstract class CachedListVirtualDelegate implements ILi return this.cache.get(element) ?? this.estimateHeight(element); } + protected getCachedHeight(element: T): number | undefined { + return this.cache.get(element); + } + protected abstract estimateHeight(element: T): number; abstract getTemplateId(element: T): string; diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 5c62e99faf8..831e610665e 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -1620,12 +1620,7 @@ export class ListView implements IListView { private probeDynamicHeight(index: number): number { const item = this.items[index]; - const diff = this.probeDynamicHeightForItem(item, index); - if (diff > 0) { - this.virtualDelegate.setDynamicHeight?.(item.element, item.size); - } - - return diff; + return this.probeDynamicHeightForItem(item, index); } private probeDynamicHeightForItem(item: IItem, index: number): number { @@ -1635,6 +1630,7 @@ export class ListView implements IListView { const size = item.size; item.size = newSize; item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return newSize - size; } } @@ -1660,6 +1656,7 @@ export class ListView implements IListView { } } item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return item.size - size; } @@ -1678,12 +1675,19 @@ export class ListView implements IListView { renderer.disposeElement?.(item.element, index, row.templateData); item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); row.domNode.remove(); this.cache.release(row); return item.size - size; } + private publishDynamicHeight(item: IItem): void { + if (item.size > 0) { + this.virtualDelegate.setDynamicHeight?.(item.element, item.size); + } + } + getElementDomId(index: number): string { return `${this.domId}_${index}`; } diff --git a/src/vs/base/common/managedSettings.ts b/src/vs/base/common/managedSettings.ts index 56e99e0ef0b..1d5205a0b99 100644 --- a/src/vs/base/common/managedSettings.ts +++ b/src/vs/base/common/managedSettings.ts @@ -38,6 +38,10 @@ export interface IStrictMarketplaceSource { * * Plain-string entries (allowed by the policy schema but unnamed) are stored with * the value used as both key and value so they survive the round-trip intact. + * + * Marketplace names come from managed settings (untrusted input) and are written as object keys, + * so `__proto__` / `constructor` / `prototype` keys are skipped to avoid prototype pollution + * (mirroring the guard in the managed-settings normalizer's string-map encoder). */ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | IExtraKnownMarketplaceEntry)[] | undefined): Record | undefined { if (!entries?.length) { @@ -46,8 +50,14 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I const obj: Record = {}; for (const entry of entries) { if (typeof entry === 'string') { + if (isUnsafeMarketplaceKey(entry)) { + continue; + } obj[entry] = entry; } else { + if (isUnsafeMarketplaceKey(entry.name)) { + continue; + } const s = entry.source; const base = s.source === 'github' ? s.repo : s.url; obj[entry.name] = s.ref ? `${base}#${s.ref}` : base; @@ -55,3 +65,8 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I } return obj; } + +/** Whether a marketplace name would pollute the prototype chain if used as an object key. */ +function isUnsafeMarketplaceKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 37426c29206..6b936d7823f 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; +import { CachedListVirtualDelegate, IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; import { ListView } from '../../../../browser/ui/list/listView.js'; import { range } from '../../../../common/arrays.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; @@ -40,4 +40,67 @@ suite('ListView', function () { listView.dispose(); assert.strictEqual(templatesCount, 0, 'all templates have been disposed'); }); + + test('publishes freshly measured dynamic heights', function () { + const element = document.createElement('div'); + element.style.height = '200px'; + element.style.width = '200px'; + document.body.appendChild(element); + + type TestElement = { height: number }; + const delegate = new class extends CachedListVirtualDelegate { + protected estimateHeight() { return 100; } + getTemplateId() { return 'template'; } + hasDynamicHeight() { return true; } + getMeasuredHeight(element: TestElement) { return this.getCachedHeight(element); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate(container) { + const content = document.createElement('div'); + container.appendChild(content); + return content; + }, + renderElement(element, _index, templateData) { templateData.style.height = `${element.height}px`; }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(element, delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(200, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => delegate.getMeasuredHeight(element)), [40, 100, 160]); + } finally { + listView.dispose(); + element.remove(); + } + }); + + test('publishes positive delegate-provided dynamic heights', function () { + type TestElement = { height: number }; + const publishedHeights = new Map(); + const delegate: IListVirtualDelegate = { + getHeight() { return 100; }, + getTemplateId() { return 'template'; }, + getDynamicHeight(element) { return element.height; }, + setDynamicHeight(element, height) { publishedHeights.set(element, height); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate() { }, + renderElement() { }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 0 }, { height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(400, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => publishedHeights.get(element)), [undefined, 40, 100, 160]); + } finally { + listView.dispose(); + } + }); }); diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 109b4d8703a..e6a32fc09f2 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -47,6 +47,7 @@ export const enum AccessibleViewProviderId { OutputFindHelp = 'outputFindHelp', ProblemsFilterHelp = 'problemsFilterHelp', SessionsChat = 'sessionsChat', + SessionsChanges = 'sessionsChanges', Survey = 'survey', } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 9ed29001347..de8b2bee3be 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -1092,6 +1092,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC case 'root/sessionAdded': case 'root/sessionRemoved': case 'root/sessionSummaryChanged': + case 'root/progress': case 'auth/required': { this._logService.trace(`[RemoteAgentHostProtocol] Notification: ${msg.method}`); // The case narrows `msg.method` to a single literal; the matching params diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 32a9f737713..6a4a6fdc4f4 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -117,9 +117,9 @@ export interface IAgentHostChangesetOperationService extends IDisposable { updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void; /** - * Returns the operations that should be advertised for the given changeset, or - * `undefined` when no operations are available. - */ + * Returns the operations that should be advertised for the given changeset, or + * `undefined` when no operations are available. + */ getOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined; /** diff --git a/src/vs/platform/agentHost/common/agentHostChangesetService.ts b/src/vs/platform/agentHost/common/agentHostChangesetService.ts index 961dd90bf56..956d9eb0242 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetService.ts @@ -181,6 +181,11 @@ export interface IAgentHostChangesetService { */ isStaticChangesetComputeActive(changesetUri: ProtocolURI): boolean; + /** + * Refreshes the list of changesets for the given session. + */ + refreshChangesetCatalog(session: ProtocolURI): void; + /** * Lazy refresh of the branch changeset, kicked off when a client * first subscribes to `/changeset/branch`. Self-defers when the diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index c6109b298ef..d42a38bc0a2 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -792,6 +792,13 @@ export interface IAgentCreateSessionConfig { */ readonly turnIdMapping?: ReadonlyMap; }; + /** + * MCP-style opt-in progress token from the client's `createSession`. When + * set, the service reports any long-running session bring-up work — chiefly + * the lazy first-use SDK download — as `progress` notifications carrying + * this token, so the client can correlate them to this call. + */ + readonly progressToken?: string; } /** Options for creating an additional chat within a session. */ diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 3de44b3dced..5795e915a62 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -87,7 +87,7 @@ export const compareTurnsChangesetDescription = (): string => localize('compareT * Returns `undefined` only when no branch name is known at all, so * callers can omit the description entirely. */ -export function formatSessionChangesetDescription(gitState: ISessionGitState): string | undefined { +export function formatBranchChangesetDescription(gitState: ISessionGitState): string | undefined { const { baseBranchName, branchName, upstreamBranchName } = gitState; // Use branch name @@ -292,10 +292,17 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig * compare-turns diffs construct the URI themselves from two known * turn ids and subscribe directly. */ -export function buildDefaultChangesetCatalogue(sessionUri: URI): Changeset[] { +export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessionGitState): Changeset[] { + if (!gitState) { + return []; + } + return [ { label: branchChangesetLabel(), + description: gitState + ? formatBranchChangesetDescription(gitState) + : undefined, uriTemplate: buildBranchChangesetUri(sessionUri), changeKind: ChangesetKind.Branch }, diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 0036c31360b..7c99a950fb6 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -80454fd +e793e92 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 985f37aa5fd..bfa9769c1bb 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -103,6 +103,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -141,6 +142,7 @@ export type ServerChatAction = | ChatToolCallReadyAction | ChatTurnCompleteAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatInputRequestedAction @@ -290,6 +292,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnComplete]: false, [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, + [ActionType.ChatActivityChanged]: false, [ActionType.ChatUsage]: false, [ActionType.ChatReasoning]: false, [ActionType.ChatPendingMessageSet]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 94e7d395276..dcfd3324295 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -386,6 +386,23 @@ export interface ChatErrorAction { _meta?: Record; } +/** + * The activity description of this chat changed. + * + * Dispatched by the server to indicate what the chat is currently doing + * (e.g. running a tool, thinking). Clear activity by omitting it or setting it + * to `undefined`. + * Producers SHOULD also update the parent session's chat catalog with + * `session/chatUpdated` so `ChatSummary.activity` stays in sync. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatActivityChangedAction { + type: ActionType.ChatActivityChanged; + /** Human-readable description of current activity; omit or set `undefined` to clear */ + activity?: string; +} /** * Token usage report for a turn. @@ -626,6 +643,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 0bd64ae6f1f..71eb53e028f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -312,6 +312,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatError: return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + case ActionType.ChatActivityChanged: + return { ...state, activity: action.activity }; + // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index 4373a5fbcc9..4032839fbc0 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -143,3 +143,82 @@ export interface SessionSummaryChangedParams { */ changes: Partial; } + +// ─── progress ──────────────────────────────────────────────────────────────── + +/** + * Generic progress notification for a long-running operation. + * + * A client opts in to progress for a request by including a `progressToken` in + * that request (today: the `progressToken` field on `createSession`). If the + * server does long-running work to service the request — e.g. lazily + * downloading an agent's native SDK the first time a session of that provider + * is materialized — it emits `progress` notifications carrying the same token. + * + * The notification is operation-agnostic: it says nothing about *what* is + * progressing. The client correlates `progressToken` back to the request it + * originated from (and thus the UI surface awaiting it) and renders its own + * localized indicator. The same channel serves any future long-running + * operation without a new method. + * + * Semantics: + * + * - `progress` is monotonically non-decreasing for a given `progressToken`. + * - `total` is present only when the server knows the magnitude up front + * (e.g. a `Content-Length`); when absent the client SHOULD show an + * indeterminate indicator. + * - The operation is complete when `progress === total`. The server MUST emit a + * final frame satisfying `progress === total`; when the total was never + * known, it sets `total` to the final `progress` on that frame. No further + * frames reference the token afterwards. + * - The server MAY emit no progress at all (e.g. the work was already done); + * the client then never shows an indicator. + * - Like all notifications this is ephemeral and is **not** replayed on + * reconnect. A client that never receives the terminal frame SHOULD expire + * the indicator after an idle timeout. + * + * @category Protocol Notifications + * @method root/progress + * @direction Server → Client + * @messageType Notification + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "root/progress", + * "params": { + * "channel": "ahp-root://", + * "progressToken": "9b2c1f7e-4a0d-4e2b-8b1a-2f7e4a0d4e2b", + * "progress": 18874368, + * "total": 41957498 + * } + * } + * ``` + */ +export interface ProgressParams { + /** Channel URI this notification belongs to (the root channel). */ + channel: URI; + /** + * Echoes the `progressToken` the client supplied on the originating request + * (e.g. the `progressToken` field of `createSession`), correlating this frame + * to that call. Unique across the client's active requests. + */ + progressToken: string; + /** + * Progress so far, in operation-defined units (e.g. bytes received). + * Monotonically non-decreasing for a given `progressToken`. + */ + progress: number; + /** + * Total when known up front (e.g. from a `Content-Length`); omitted ⇒ + * indeterminate. The operation is complete once `progress === total`. + */ + total?: number; + /** + * Optional human-readable progress message. The client owns its own + * (localized) presentation derived from the originating request; generic + * clients that don't track the token MAY display this instead. + */ + message?: string; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index 3c96acf6c88..010c689452e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -84,6 +84,19 @@ export interface CreateSessionParams extends BaseParams { * `clientId` the creating client supplied in `initialize`. */ activeClient?: SessionActiveClient; + /** + * Opt-in progress token. When set, the client is offering to receive + * `progress` notifications (see `ProgressParams`) for any long-running work + * the server does to bring this session up — most notably the lazy, + * first-use download of the provider's native SDK. The server echoes this + * exact token on every `progress` frame so the client can correlate it to + * this `createSession` call (and the UI awaiting it). + * + * The token MUST be unique across the client's active requests. The server + * MAY ignore it (e.g. when nothing long-running is needed), in which case no + * `progress` notifications are emitted. + */ + progressToken?: string; } // ─── disposeSession ────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index c86f4d6ea6f..f83a5beabe1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -223,11 +223,9 @@ export interface ProjectInfo { * `Error` — bits 0–4) from the * {@link SessionState.defaultChat | default chat} when present, else from * the most recently modified chat. **Promote** `InputNeeded` whenever any - * chat in the session needs input, **promote** `Error` whenever any chat is - * in an error state, and **promote** `InProgress` whenever any chat is - * actively streaming — all override the default-chat bits, with precedence - * `InputNeeded` > `Error` > `InProgress`. The orthogonal flag bits - * (`IsRead`, `IsArchived`) remain session-scoped. + * chat in the session needs input, and **promote** `Error` whenever any + * chat is in an error state — both override the default-chat bits. The + * orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped. * - `activity`: mirror the activity string of the default chat, or of the * chat currently driving the promoted status bits when a non-default chat * wins (e.g. the chat that raised `InputNeeded`). diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 8cc4a732652..62654a652ed 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -12,7 +12,7 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction } from '../channels-chat/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -51,6 +51,7 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', + ChatActivityChanged = 'chat/activityChanged', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', @@ -176,6 +177,7 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index bf010f1d70a..3eb000893c7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -15,7 +15,7 @@ import type { CreateResourceWatchParams, CreateResourceWatchResult } from '../ch import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../channels-changeset/commands.js'; import type { ActionEnvelope } from './actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams } from '../channels-root/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams } from '../channels-root/notifications.js'; import type { AuthRequiredParams } from './notifications.js'; import type { OtlpExportLogsParams, OtlpExportTracesParams, OtlpExportMetricsParams } from '../channels-otlp/notifications.js'; import type { AhpError } from './errors.js'; @@ -166,6 +166,7 @@ export interface ServerNotificationMap { 'root/sessionAdded': { params: SessionAddedParams }; 'root/sessionRemoved': { params: SessionRemovedParams }; 'root/sessionSummaryChanged': { params: SessionSummaryChangedParams }; + 'root/progress': { params: ProgressParams }; 'auth/required': { params: AuthRequiredParams }; 'otlp/exportLogs': { params: OtlpExportLogsParams }; 'otlp/exportTraces': { params: OtlpExportTracesParams }; diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 205ab7c4f3f..83c039f0501 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -112,6 +112,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnComplete]: '0.4.0', [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', + [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', @@ -178,6 +179,7 @@ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in ProtocolNotificationMe 'root/sessionAdded': '0.1.0', 'root/sessionRemoved': '0.1.0', 'root/sessionSummaryChanged': '0.1.0', + 'root/progress': '0.5.0', 'auth/required': '0.1.0', 'otlp/exportLogs': '0.2.0', 'otlp/exportTraces': '0.2.0', diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index bb5b0caf0fc..0702e0c0b05 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -77,6 +77,7 @@ export { type SessionAddedParams, type SessionRemovedParams, type SessionSummaryChangedParams, + type ProgressParams, type AuthRequiredParams, } from './protocol/notifications.js'; @@ -90,6 +91,7 @@ export const NotificationType = { SessionAdded: 'root/sessionAdded', SessionRemoved: 'root/sessionRemoved', SessionSummaryChanged: 'root/sessionSummaryChanged', + Progress: 'root/progress', AuthRequired: 'auth/required', } as const; export type NotificationType = typeof NotificationType[keyof typeof NotificationType]; @@ -127,7 +129,7 @@ import type { RootConfigChangedAction, } from './protocol/actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, AuthRequiredParams } from './protocol/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_ } from './protocol/action-origin.generated.js'; /** @@ -140,6 +142,7 @@ export type ProtocolNotification = | ({ type: 'root/sessionAdded' } & SessionAddedParams) | ({ type: 'root/sessionRemoved' } & SessionRemovedParams) | ({ type: 'root/sessionSummaryChanged' } & SessionSummaryChangedParams) + | ({ type: 'root/progress' } & ProgressParams) | ({ type: 'auth/required' } & AuthRequiredParams); export type RootAction = IRootAction_; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 108529b7382..19ec655fdb6 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { IAgentSessionMetadata } from '../common/agentService.js'; -import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, formatSessionChangesetDescription as formatBranchChangesChangesetDescription, parseChangesetUri } from '../common/changesetUri.js'; +import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; import { ChangesetFileMonitorCoordinator } from './agentHostChangesetFileMonitorCoordinator.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetService, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS } from '../common/agentHostChangesetService.js'; @@ -14,7 +14,7 @@ import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChang import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { isAhpChatChannel, readSessionGitState } from '../common/state/sessionState.js'; +import { isAhpChatChannel } from '../common/state/sessionState.js'; /** * Raw metadata blob values for the session DB, batch-read by the caller. @@ -71,6 +71,7 @@ export class AgentHostChangesetCoordinator extends Disposable { * `SessionReady` is dispatched. */ onSessionCreated(sessionStr: string): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.registerStaticChangesets(sessionStr); } @@ -82,6 +83,7 @@ export class AgentHostChangesetCoordinator extends Disposable { * keys. */ onSessionRestored(sessionStr: string, metadata: IChangesetSessionMetadata): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.registerStaticChangesets(sessionStr); this._changesets.restorePersistedStaticChangesets(sessionStr, { branchRaw: metadata[META_CHANGESET_BRANCH], @@ -101,7 +103,9 @@ export class AgentHostChangesetCoordinator extends Disposable { * because the working directory was not yet known. */ onSessionMaterialized(sessionStr: string): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.onWorkingDirectoryAvailable(sessionStr); + this._changesetFileMonitor.onSessionMaterialized(sessionStr); } @@ -329,82 +333,12 @@ export class AgentHostChangesetCoordinator extends Disposable { * Called when a session's Git state is refreshed. */ private onDidRunSessionGitStateRefresh(sessionStr: string): void { + // Refresh the list of changesets for the session. + this._changesets.refreshChangesetCatalog(sessionStr); + // Git state has been refreshed so we need to recompute every // changeset currently subscribed for the session (the service // reads the exposed subscription list). this._changesets.recomputeSubscribedChangesets(sessionStr); - - // Remove any changesets that are only relevant to Git state. - this._removeGitOnlyChangesets(sessionStr); - - // Update the description of the branch changeset. - this._updateBranchChangesetDescription(sessionStr); - } - - private _removeGitOnlyChangesets(sessionStr: string): void { - const state = this._stateManager.getSessionState(sessionStr); - const gitState = readSessionGitState(state?._meta); - if (gitState) { - return; - } - - const currentChangesets = state?.changesets; - if (!currentChangesets || currentChangesets.length === 0) { - return; - } - - const branchUri = buildBranchChangesetUri(sessionStr); - const sessionUri = buildSessionChangesetUri(sessionStr); - const uncommittedUri = buildUncommittedChangesetUri(sessionStr); - - const nextChangesets = currentChangesets - .filter(c => c.uriTemplate !== branchUri && - c.uriTemplate !== sessionUri && - c.uriTemplate !== uncommittedUri); - if (nextChangesets.length === currentChangesets.length) { - return; - } - - this._stateManager.setSessionChangesets(sessionStr, nextChangesets); - } - - private _updateBranchChangesetDescription(sessionStr: string): void { - const state = this._stateManager.getSessionState(sessionStr); - const gitState = readSessionGitState(state?._meta); - if (!gitState) { - return; - } - - const changesets = state?.changesets; - if (!changesets || changesets.length === 0) { - return; - } - - const branchUri = buildBranchChangesetUri(sessionStr); - const description = formatBranchChangesChangesetDescription(gitState); - - let changed = false; - const nextChangesets = changesets.map(changeset => { - if (changeset.uriTemplate !== branchUri) { - return changeset; - } - if (changeset.description === description) { - return changeset; - } - - changed = true; - if (description === undefined) { - const { description: _omit, ...rest } = changeset; - return rest; - } - - return { ...changeset, description }; - }); - - if (!changed) { - return; - } - - this._stateManager.setSessionChangesets(sessionStr, nextChangesets); } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 995490df8ad..88ceac5c677 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -49,12 +49,12 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } - getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined { + getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] { if (!gitState) { const sessionState = this._stateManager.getSessionState(sessionKey); gitState = readSessionGitState(sessionState?._meta); if (!gitState) { - return undefined; + return []; } } @@ -64,7 +64,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA const parsed = parseChangesetUri(changeset); if (!parsed) { - return undefined; + return []; } return this._getOperations({ @@ -120,7 +120,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA this._stateManager.dispatchServerAction(changeset, { type: ActionType.ChangesetOperationsChanged, - operations: operations ? [...operations] : undefined, + operations: [...operations], }); } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index a2eea6cfbf3..f4d6c48ad5a 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -16,6 +16,7 @@ import { buildUncommittedChangesetUri, parseChangesetUri, ChangesetKind, + buildDefaultChangesetCatalog, } from '../common/changesetUri.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; @@ -28,6 +29,7 @@ import { type URI as ProtocolURI, readSessionGitState, isDefaultChatUri, + SessionLifecycle, } from '../common/state/sessionState.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; @@ -85,7 +87,7 @@ function summariseDiffs(diffs: readonly ISessionFileDiff[] | undefined): Changes * Only the `changeKind: 'session'` entry feeds the summary; other kinds * (`'uncommitted'`, `'turn'`, `'compare-turns'`) describe slices, not * the session-level footprint. The static catalogue itself (built by - * {@link buildDefaultChangesetCatalogue}) is independent of counts and + * {@link buildDefaultChangesetCatalog}) is independent of counts and * is seeded once at session creation. */ function computeChangesSummaryFromLiveState( @@ -304,6 +306,17 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this.restoreStaticChangeset(session, kind, diffs); } + refreshChangesetCatalog(session: ProtocolURI): void { + const state = this._stateManager.getSessionState(session); + if (state?.lifecycle !== SessionLifecycle.Ready) { + return; + } + + const gitState = readSessionGitState(state?._meta); + const changesets = buildDefaultChangesetCatalog(session, gitState); + this._stateManager.setSessionChangesets(session, changesets); + } + refreshBranchChangeset(session: ProtocolURI): void { if (!this._hasWorkingDirectory(session)) { this._pendingMaterialization.add(session); diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index 51b5a1cd93d..d7eea1afbfa 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -32,13 +32,13 @@ export class AgentHostCommitOperationContribution extends Disposable implements return store; } - getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { if ((gitState?.uncommittedChanges ?? 0) <= 0) { - return undefined; + return []; } if (!gitHubState?.pullRequestUrl && changesetKind !== 'uncommitted') { - return undefined; + return []; } return [{ diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts index 507eef496f0..cb6935a324a 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts @@ -30,9 +30,9 @@ export class AgentHostDiscardChangesOperationContribution extends Disposable imp return store; } - getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] { if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) { - return undefined; + return []; } return [{ diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index cc6e9cd1c2b..629a0cf7444 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -7,7 +7,7 @@ import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, type Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { joinPath } from '../../../base/common/resources.js'; import { isWindows } from '../../../base/common/platform.js'; @@ -29,7 +29,7 @@ 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 { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; @@ -132,6 +132,9 @@ async function startAgentHost(): Promise { // Create the real service implementation that lives in this process let agentService: AgentService; let instantiationService: IInstantiationService; + // Hoisted out of the `try` below so the protocol handlers (constructed + // after the block) can forward agent-SDK download progress to clients. + let sdkDownloadProgress: Event | undefined; try { // Build the DI container early so the git service can be created via // `createInstance` (it needs IFileService + INativeEnvironmentService). @@ -162,8 +165,9 @@ async function startAgentHost(): Promise { // Register the agent SDK downloader BEFORE any service that injects it // (ClaudeAgentSdkService and CodexAgent below). The downloader resolves // dev-override env var → on-disk cache → product.agentSdks download. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); diServices.set(ICopilotApiService, copilotApiService); diServices.set(ICopilotBranchNameGenerator, instantiationService.createInstance(CopilotBranchNameGenerator)); @@ -227,6 +231,23 @@ async function startAgentHost(): Promise { logService.error('Failed to create AgentService', err); throw err; } + + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both the local (IPC) and any external (WebSocket) renderer + // receive them via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 6b26b821fbf..252dd06cda9 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -15,6 +15,7 @@ globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta. import * as fs from 'fs'; import * as os from 'os'; +import type { Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { raceTimeout } from '../../../base/common/async.js'; import { joinPath } from '../../../base/common/resources.js'; @@ -41,7 +42,7 @@ 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 { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentService } from './agentService.js'; @@ -249,6 +250,7 @@ async function main(): Promise { diServices.set(IAgentService, agentService); // Register agents + let sdkDownloadProgress: Event | undefined; if (!options.quiet) { // Production agents (require DI) const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); @@ -273,8 +275,9 @@ async function main(): Promise { process.env[AgentHostCodexAgentSdkRootEnvVar] = options.codexSdkRoot; } // Register the agent SDK downloader BEFORE any service that injects it. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); diServices.set(IClaudeProxyService, claudeProxyService); const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); @@ -311,6 +314,22 @@ async function main(): Promise { } } + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both local (IPC) and remote (WebSocket) renderers receive them + // via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + if (options.enableMockAgent) { // Dynamic import to avoid bundling test code in production import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 48a7baca9a2..7af898c74f3 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -9,7 +9,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; 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 { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type ProgressParams } 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, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, 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'; @@ -1354,4 +1354,21 @@ export class AgentHostStateManager extends Disposable { const activityBits = chatStatus & ~(SessionStatus.IsRead | SessionStatus.IsArchived); return activityBits | metaFlags; } + + /** + * Emit a generic progress notification on the root channel, correlated to + * the originating request by {@link ProgressParams.progressToken}. Routed to + * clients through the same {@link onDidEmitNotification} path as session + * notifications, so both the local (IPC proxy) and remote (WebSocket + * {@link ProtocolServerHandler}) renderers receive it without any + * transport-specific special casing. Progress for host-level work (e.g. a + * shared SDK download) rides the root channel rather than a per-session one. + */ + emitProgress(progress: Omit): void { + this._onDidEmitNotification.fire({ + type: 'root/progress', + channel: ROOT_STATE_URI, + ...progress, + }); + } } diff --git a/src/vs/platform/agentHost/node/agentSdkDownloader.ts b/src/vs/platform/agentHost/node/agentSdkDownloader.ts index 32c86eacaa1..55ce50b79f6 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloader.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloader.ts @@ -8,9 +8,12 @@ import * as tar from 'tar'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import * as path from '../../../base/common/path.js'; import { format2 } from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; import { detectLibcSync, type LibcFamily } from '../../../base/node/libc.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { FileOperationError, FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; @@ -47,6 +50,12 @@ import { IRequestContext } from '../../../base/parts/request/common/request.js'; export interface IAgentSdkPackage { /** Key under `product.agentSdks` — e.g. `'claude'`, `'codex'`. */ readonly id: string; + /** + * Brand display name for user-facing progress, e.g. `'Claude'`, `'Codex'`. + * The downloader puts this on {@link IAgentSdkDownloadProgress.displayName} + * so clients can build a localized "Downloading {displayName} agent…" label. + */ + readonly displayName: string; /** Env var that, when set, becomes the SDK root and short-circuits the download. */ readonly devOverrideEnvVar: string; /** @@ -111,9 +120,46 @@ export function resolveSdkTarget( export const IAgentSdkDownloader = createDecorator('agentSdkDownloader'); +/** Lifecycle phase of a single SDK download (downloader-internal). */ +export type AgentSdkDownloadPhase = 'started' | 'progress' | 'completed' | 'failed'; + +/** + * A process-global download-progress sample fired on + * {@link IAgentSdkDownloader.onDidDownloadProgress}. The downloader owns the + * lifecycle: one `started`, throttled `progress` frames, then exactly one + * terminal `completed` / `failed` — all sharing a `downloadId`. Concurrent + * `loadSdkRoot` callers for the same tarball are deduped, so they observe one + * shared download (one `downloadId`). + */ +export interface IAgentSdkDownloadProgress { + /** Stable id for one download; coalesces frames and distinguishes concurrent fetches. */ + readonly downloadId: string; + /** Package id, e.g. `'claude'` / `'codex'`. */ + readonly packageId: string; + /** Brand display name, e.g. `'Claude'`. */ + readonly displayName: string; + /** Lifecycle phase of this frame. */ + readonly phase: AgentSdkDownloadPhase; + /** Bytes written so far. Monotonically non-decreasing within a `downloadId`. */ + readonly receivedBytes: number; + /** Total bytes from `Content-Length`, or `undefined` when unknown (indeterminate). */ + readonly totalBytes: number | undefined; + /** Short, non-localized failure reason; present only when `phase: 'failed'`. */ + readonly error?: string; +} + export interface IAgentSdkDownloader { readonly _serviceBrand: undefined; + /** + * Fires while a tarball is being fetched (cold cache only): one `started`, + * throttled `progress` samples, then one terminal `completed` / `failed`. + * Never fires for dev-override or cache-hit resolutions (no bytes move). + * Process-global so a single subscriber (the protocol server) can forward + * progress to clients regardless of which session triggered the fetch. + */ + readonly onDidDownloadProgress: Event; + /** * Returns the absolute path of the SDK root directory — the directory that * contains the package's `node_modules/` subtree. Callers resolve the @@ -138,6 +184,20 @@ export interface IAgentSdkDownloader { * download. */ isAvailable(pkg: IAgentSdkPackage): boolean; + + /** + * True iff {@link loadSdkRoot} would resolve WITHOUT a network download — + * the dev override is set, or a completed cache for the configured version + * already exists on disk. False when product config is present but the + * cache is cold (a fetch would be required), and false when neither an + * override nor product config is configured. + * + * Performs at most a single sentinel `exists` check and never downloads. + * Eager / background callers (e.g. a provider listing its sessions at + * startup) use this to avoid kicking off a multi-second cold download + * before the user has asked for anything. + */ + isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise; } // #endregion @@ -147,9 +207,31 @@ export interface IAgentSdkDownloader { /** How long a `loadSdkRoot` failure latches before we try again. */ const LOAD_FAILURE_NEGATIVE_CACHE_MS = 30_000; -export class AgentSdkDownloader implements IAgentSdkDownloader { +/** + * Minimum gap between download-progress samples. A 70-95MB tarball over a fast + * link produces thousands of chunks; without throttling we'd flood the progress + * channel. ~250ms keeps the percentage visibly moving without spamming. + */ +const PROGRESS_EMIT_THROTTLE_MS = 250; + +/** + * Parses a `Content-Length` header into a positive integer byte count, or + * `undefined` when the header is absent, an array, or not a clean integer. + */ +function parseContentLength(header: string | string[] | undefined): number | undefined { + if (typeof header !== 'string' || !/^\d+$/.test(header)) { + return undefined; + } + const parsed = parseInt(header, 10); + return parsed > 0 ? parsed : undefined; +} + +export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloader { declare readonly _serviceBrand: undefined; + private readonly _onDidDownloadProgress = this._register(new Emitter()); + readonly onDidDownloadProgress: Event = this._onDidDownloadProgress.event; + /** * In-flight downloads keyed by the destination `cacheDir` (which * already encodes `//`). Concurrent @@ -180,7 +262,9 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { @IRequestService private readonly _requestService: IRequestService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, - ) { } + ) { + super(); + } isAvailable(pkg: IAgentSdkPackage): boolean { if (process.env[pkg.devOverrideEnvVar]) { @@ -189,6 +273,22 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return !!this._productService.agentSdks?.[pkg.id] && resolveSdkTarget(pkg) !== undefined; } + async isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise { + if (process.env[pkg.devOverrideEnvVar]) { + return true; + } + const config = this._productService.agentSdks?.[pkg.id]; + if (!config) { + return false; + } + const sdkTarget = resolveSdkTarget(pkg); + if (!sdkTarget) { + return false; + } + const sentinel = URI.joinPath(URI.file(this._cacheDir(pkg.id, config.version, sdkTarget)), '.complete'); + return this._fileService.exists(sentinel); + } + async loadSdkRoot(pkg: IAgentSdkPackage, token: CancellationToken): Promise { // 1. Dev override. const override = process.env[pkg.devOverrideEnvVar]; @@ -310,9 +410,21 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { await this._delIgnoringMissing(tmpDirUri); await this._fileService.createFolder(tmpDirUri); + // Fire the download lifecycle on the process-global event so a single + // subscriber (the protocol server) can forward it to clients. One + // `started`, throttled `progress` from `_fetch`, then a terminal frame. + const downloadId = generateUuid(); + let lastReceived = 0; + let lastTotal: number | undefined; + this._fireProgress(pkg, downloadId, 'started', 0, undefined); + try { const tarballPath = path.join(tmpDir, 'sdk.tgz'); - await this._fetch(url, tarballPath, token); + await this._fetch(url, tarballPath, token, (receivedBytes, totalBytes) => { + lastReceived = receivedBytes; + lastTotal = totalBytes; + this._fireProgress(pkg, downloadId, 'progress', receivedBytes, totalBytes); + }); await this._extractTarGz(tarballPath, tmpDir); await this._fileService.del(URI.file(tarballPath)); @@ -334,6 +446,7 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { } catch (err) { if (await this._handleRenameLoser(err, sentinel, tmpDirUri)) { this._logService.info(`[AgentSdkDownloader] ${pkg.id}: lost rename race, using existing cache`); + this._fireProgress(pkg, downloadId, 'completed', lastReceived, lastTotal); return cacheDir; } throw err; @@ -341,21 +454,44 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { const elapsed = Math.round((Date.now() - start) / 1000); this._logService.info(`[AgentSdkDownloader] ${pkg.id}: downloaded in ${elapsed}s`); + this._fireProgress(pkg, downloadId, 'completed', lastTotal ?? lastReceived, lastTotal); return cacheDir; } catch (err) { await this._delIgnoringMissing(tmpDirUri); if (token.isCancellationRequested) { + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, 'cancelled'); throw new CancellationError(); } + const message = err instanceof Error ? err.message : String(err); + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, message); throw new Error( `Failed to download ${pkg.id} SDK from ${url} ` + `(cache target: ${cacheDir}). ` + `Set ${pkg.devOverrideEnvVar} to a local SDK root to bypass. ` + - `Cause: ${err instanceof Error ? err.message : String(err)}`, + `Cause: ${message}`, ); } } + private _fireProgress( + pkg: IAgentSdkPackage, + downloadId: string, + phase: AgentSdkDownloadPhase, + receivedBytes: number, + totalBytes: number | undefined, + error?: string, + ): void { + this._onDidDownloadProgress.fire({ + downloadId, + packageId: pkg.id, + displayName: pkg.displayName, + phase, + receivedBytes, + totalBytes, + ...(error !== undefined ? { error } : {}), + }); + } + private async _handleRenameLoser( err: unknown, sentinel: URI, @@ -375,7 +511,12 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return true; } - private async _fetch(url: string, dest: string, token: CancellationToken): Promise { + private async _fetch( + url: string, + dest: string, + token: CancellationToken, + onBytes?: (receivedBytes: number, totalBytes: number | undefined) => void, + ): Promise { // Delegate to IRequestService (corporate proxy, strictSSL, kerberos, // retries, redirect follow). `fs.createWriteStream` (not // `IFileService.writeFile`) so that cancelling a multi-MB download @@ -401,9 +542,31 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { throw new Error(`HTTP ${statusCode} fetching ${url}`); } + // The CDN sends `Content-Length` for these static tarballs, which lets + // us report determinate percentage progress. A missing/garbled header + // degrades gracefully to an indeterminate (byte-count only) report. + const totalBytes = parseContentLength(context.res.headers['content-length']); + await new Promise((resolve, reject) => { const out = fs.createWriteStream(dest); let settled = false; + // Throttle progress so a fast link doesn't fire thousands of + // samples. The first chunk always passes (lastEmit starts at 0) + // and 'end' forces a final sample, so consumers see a start and a + // 100% finish regardless of chunk timing. + let receivedBytes = 0; + let lastEmitTime = 0; + const emitBytes = (force: boolean) => { + if (!onBytes) { + return; + } + const now = Date.now(); + if (!force && now - lastEmitTime < PROGRESS_EMIT_THROTTLE_MS) { + return; + } + lastEmitTime = now; + onBytes(receivedBytes, totalBytes); + }; const settleResolve = () => { if (settled) { return; } settled = true; @@ -428,11 +591,16 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { // resume on 'drain'. out.on('drain', () => context.stream.resume()); context.stream.on('data', chunk => { + receivedBytes += chunk.byteLength; + emitBytes(false); if (!out.write(chunk.buffer)) { context.stream.pause(); } }); - context.stream.on('end', () => out.end()); + context.stream.on('end', () => { + emitBytes(true); + out.end(); + }); context.stream.on('error', settleReject); }); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index ebd1f29200c..56b6dd10f77 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -22,7 +22,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection. import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; -import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; import { ActionType, ActionEnvelope, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -119,6 +119,18 @@ export class AgentService extends Disposable implements IAgentService { private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ private readonly _sessionToProvider = new Map(); + /** + * Sessions that have opted in to bring-up progress, keyed by provider id. + * A session is added here when its `createSession` carries a + * {@link IAgentCreateSessionConfig.progressToken} and removed once it + * materializes (the SDK is now resolved) or is disposed. The SDK download is + * host-level and shared across every session of a provider, so this only + * records *interest*: as long as one or more sessions of a provider is + * registered, {@link emitDownloadProgress} surfaces that provider's download as a single + * progress stream keyed by the download's own identity (the package id), + * rather than one stream per session. + */ + private readonly _downloadProgressInterest = new Map>(); /** Subscriptions to provider progress events; cleared when providers change. */ private readonly _providerSubscriptions = this._register(new DisposableStore()); private readonly _authService: AgentHostAuthenticationService; @@ -606,6 +618,19 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); this._sessionToProvider.set(session.toString(), provider.id); + // Record this session's opt-in so a cold SDK download triggered at + // materialization (first message) is surfaced as progress. The download + // is provider-global, so we only track interest here; emission is keyed + // by the download's own identity, not this token. Cleared on + // materialize/dispose. + if (config?.progressToken) { + let sessions = this._downloadProgressInterest.get(provider.id); + if (!sessions) { + sessions = new Set(); + this._downloadProgressInterest.set(provider.id, sessions); + } + sessions.add(session.toString()); + } this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); // Resolve config and seed the initial customization set in parallel so @@ -688,19 +713,6 @@ export class AgentService extends Disposable implements IAgentService { this._persistConfigValues(session, sessionConfig.values); } - // Initial changeset state is established as part of session creation, - // never deferred to materialization. Two halves: (1) the catalogue - // is seeded on `state.changesets` via `setSessionChangesets` right - // after `createSession`; (2) the backing per-changeset states are - // registered by `_changesetCoordinator.onSessionCreated` here. Both - // run before `SessionReady` is dispatched. Any future change must - // keep both halves at create time so client subscriptions resolve - // `_attachGitState` strips them once the git probe confirms the - // resolved working directory is not a git repo. Pinned by item-2 - // regression tests in `agentService.test.ts`. - const changesets = buildDefaultChangesetCatalogue(session.toString()); - this._stateManager.setSessionChangesets(session.toString(), changesets); - this._changesetCoordinator.onSessionCreated(session.toString()); if (!created.provisional) { @@ -819,6 +831,9 @@ export class AgentService extends Disposable implements IAgentService { */ private _onDidMaterializeSession(e: IAgentMaterializeSessionEvent): void { const sessionKey = e.session.toString(); + // The session is now materialized — its SDK is resolved (any cold + // download already finished), so no further progress is expected for it. + this._clearDownloadProgressInterest(sessionKey); const state = this._stateManager.getSessionState(sessionKey); if (!state) { this._logService.warn(`[AgentService] onDidMaterializeSession for unknown session: ${sessionKey}`); @@ -848,16 +863,58 @@ export class AgentService extends Disposable implements IAgentService { // Attach git state for the working directory (if present) void this._gitStateService.refreshSessionGitState(e.session.toString(), e.workingDirectory); - // Initialize the session's changesets from the catalogue - const changesets = buildDefaultChangesetCatalogue(sessionKey); - this._stateManager.setSessionChangesets(sessionKey, changesets); - // If a client subscribed to this session's uncommitted changeset // before the working directory was known, the coordinator drains // the deferred refresh now that the working directory is set. this._changesetCoordinator.onSessionMaterialized(sessionKey); } + /** Drop a session's download-progress opt-in, if any. */ + private _clearDownloadProgressInterest(sessionKey: string): void { + for (const [provider, sessions] of this._downloadProgressInterest) { + if (sessions.delete(sessionKey) && sessions.size === 0) { + this._downloadProgressInterest.delete(provider); + } + } + } + + /** + * Surface a host-level SDK download as client progress. The downloader fires + * process-global frames keyed by package id (which equals the provider id); + * because the download is shared across every session of that provider, we + * emit a SINGLE `progress` stream keyed by that package id — not one per + * session — so the client shows exactly one indicator no matter how many + * sessions of the provider are awaiting it. Frames are only emitted while at + * least one session has opted in (supplied a + * {@link IAgentCreateSessionConfig.progressToken} on `createSession`). A + * terminal frame reports `total === progress` (using `receivedBytes` when the + * size was never known) so the client dismisses the indicator deterministically. + * + * `displayName` is the provider's brand noun (e.g. `Claude`). It is woven + * into the notification's localized, human-readable `message` (e.g. + * "Downloading Claude agent…") so a generic client can render the indicator + * verbatim without knowing the resource is an agent SDK. + */ + emitDownloadProgress(packageId: string, displayName: string, receivedBytes: number, totalBytes: number | undefined, terminal: boolean): void { + const sessions = this._downloadProgressInterest.get(packageId); + if (!sessions || sessions.size === 0) { + return; + } + // On a terminal frame force `progress === total` so clients treat the + // operation as complete (covers both the determinate case and the + // indeterminate one where `totalBytes` was never known, plus failures — + // the real error surfaces via the session-failure path). + const total = terminal ? receivedBytes : totalBytes; + const message = localize('agentHost.download.agentSdkTitle', "Downloading {0} agent…", displayName); + // `progressToken` is the download's own stable identity (the package id), + // shared by every session of the provider, so the client coalesces all + // frames into one indicator and dismisses it on the terminal frame. + this._stateManager.emitProgress({ progressToken: packageId, progress: receivedBytes, total, message }); + if (terminal) { + this._downloadProgressInterest.delete(packageId); + } + } + private _persistConfigValues(session: URI, values: Record): void { let ref; try { @@ -922,6 +979,7 @@ export class AgentService extends Disposable implements IAgentService { if (provider) { await provider.disposeSession(session); this._sessionToProvider.delete(session.toString()); + this._clearDownloadProgressInterest(session.toString()); } this._changesetCoordinator.onSessionDisposed(session.toString()); this._sideEffects.cancelSessionTitleGeneration(session.toString()); @@ -1643,9 +1701,6 @@ export class AgentService extends Disposable implements IAgentService { // persisted title so they reappear after a process restart. promises.push(this._restorePeerChats(agent, session)); - const changesets = buildDefaultChangesetCatalogue(sessionStr); - this._stateManager.setSessionChangesets(sessionStr, changesets); - // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue // itself is seeded on `state.changesets` synchronously by the @@ -2197,6 +2252,7 @@ export class AgentService extends Disposable implements IAgentService { } await Promise.all(promises); this._sessionToProvider.clear(); + this._downloadProgressInterest.clear(); } // ---- helpers ------------------------------------------------------------ diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 144c9d80a52..f8864fa2b56 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -990,6 +990,15 @@ export class ClaudeAgent extends Disposable implements IAgent { // sibling Copilot provider gets nuked too. Catch and log instead. let sdkEntries: readonly SDKSessionInfo[]; try { + // Don't trigger a cold SDK download just to populate the session + // list at startup. When the SDK isn't local yet, surface an empty + // list; the download fires (with host-level progress) once the user + // starts a session, and the next `listSessions` — driven by the + // renderer's post-turn refresh — returns the full list. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring session list until a session triggers the download'); + return []; + } sdkEntries = await this._sdkService.listSessions(); } catch (err) { this._logService.warn('[Claude] SDK listSessions failed; surfacing empty list', err); diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts index 8d72ff49ecb..15651d22840 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts @@ -22,6 +22,7 @@ import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; */ export const ClaudeSdkPackage: IAgentSdkPackage = { id: 'claude', + displayName: 'Claude', devOverrideEnvVar: AgentHostClaudeSdkRootEnvVar, hasSeparateMuslLinuxPackage: true, }; @@ -54,6 +55,16 @@ export interface IClaudeAgentSdkService { getSessionMessages(sessionId: string, options?: GetSessionMessagesOptions): Promise; listSubagents(sessionId: string, options?: ListSubagentsOptions): Promise; getSubagentMessages(sessionId: string, agentId: string, options?: GetSubagentMessagesOptions): Promise; + + /** + * True iff the SDK can be loaded WITHOUT a network download — a dev + * override or dev bare-import is available, or a previously-downloaded SDK + * is cached on disk. Eager / background callers (e.g. `listSessions` at + * startup) gate on this so listing sessions never kicks off a multi-second + * cold download before the user has started a session. + */ + canLoadWithoutDownload(): Promise; + forkSession(sessionId: string, options?: ForkSessionOptions): Promise; deleteSession(sessionId: string, options?: SessionMutationOptions): Promise; createSdkMcpServer(options: { @@ -134,6 +145,17 @@ export class ClaudeAgentSdkService implements IClaudeAgentSdkService { return sdk.listSessions(undefined); } + async canLoadWithoutDownload(): Promise { + // A dev override (explicit SDK root) is always local. So is the dev + // bare-import path, which is taken when there is no product config — + // `isAvailable` is false exactly in that case. Otherwise the SDK comes + // from the downloader, which is only local once it has been cached. + if (process.env[AgentHostClaudeSdkRootEnvVar] || !this._downloader.isAvailable(ClaudeSdkPackage)) { + return true; + } + return this._downloader.isSdkResolvableWithoutDownload(ClaudeSdkPackage); + } + async getSessionInfo(sessionId: string): Promise { const sdk = await this._getSdk(); return sdk.getSessionInfo(sessionId); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index b6363c1b02e..91d021699b3 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -449,6 +449,7 @@ interface IConnectionReady { */ export const CodexSdkPackage: IAgentSdkPackage = { id: 'codex', + displayName: 'Codex', devOverrideEnvVar: AgentHostCodexAgentSdkRootEnvVar, hasSeparateMuslLinuxPackage: false, }; @@ -1766,7 +1767,16 @@ export class CodexAgent extends Disposable implements IAgent { if (!session.workingDirectory) { return; } - void this._materializeIfNeeded(session, false).then(() => { + void (async () => { + // Prewarm is a background latency optimization, not a user action, + // so it must NOT trigger a cold SDK download. When the SDK isn't + // local yet, skip prewarm; the first `sendMessage` materializes the + // thread and fires the (host-level progress-reported) download then. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info(`[Codex] SDK not downloaded yet; skipping prewarm for session=${session.sessionUri.toString()} until a message triggers the download`); + return; + } + await this._materializeIfNeeded(session, false); if (session.prewarmClaimed || session.threadId === undefined) { return; } @@ -1775,7 +1785,7 @@ export class CodexAgent extends Disposable implements IAgent { void this._expirePrewarm(session); }, CodexPrewarmTtlMs); session.prewarmTimer = prewarmTimer; - }).catch(err => { + })().catch(err => { this._logService.warn(`[Codex] prewarm failed session=${session.sessionUri.toString()}: ${err instanceof Error ? err.message : String(err)}`); }); } @@ -2243,6 +2253,15 @@ export class CodexAgent extends Disposable implements IAgent { if (!this._githubToken) { return []; } + // Don't connect (and trigger a cold SDK download) just to list threads + // at startup. When the SDK isn't local yet, surface an empty list; the + // download fires (with host-level progress) once the user starts a + // session, and the next `listSessions` — driven by the renderer's + // post-turn refresh — returns the full list. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info('[Codex] SDK not downloaded yet; deferring thread/list until a session triggers the download'); + return []; + } try { const conn = await this._ensureConnection(); const response = await conn.client.request<'thread/list', ThreadListResponse>('thread/list', { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index d2e97dabe4f..4113a13e944 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -18,13 +18,14 @@ import type { ModelSelection, ToolDefinition } from '../../common/state/protocol import type { ActiveClientToolSet } from '../activeClientState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; -import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; 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'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; /** @@ -245,13 +246,14 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } try { - this._logService.info(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); + const stopWatch = new StopWatch(); + this._logService.trace(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); const raw = await plan.client.resumeSession(plan.sessionId, { ...config, workingDirectory: plan.workingDirectory.fsPath, ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), }); - this._logService.info(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded`); + this._logService.trace(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded after ${stopWatch.elapsed()}ms`); await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); return new CopilotSessionWrapper(raw); } catch (err) { @@ -374,13 +376,10 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return { clientName: 'vscode', enableMcpApps: true, + enableFileHooks: true, onPermissionRequest: request => runtime.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), onElicitationRequest: context => runtime.handleElicitationRequest(context), - hooks: toSdkHooks(pluginsWithoutDirs.flatMap(p => p.hooks), { - onPreToolUse: input => runtime.handlePreToolUse(input), - onPostToolUse: input => runtime.handlePostToolUse(input), - }), mcpServers: { ...toSdkMcpServersFromConfigMap(plan.snapshot.mcpServers), ...toSdkMcpServers(pluginsWithoutDirs.flatMap(p => p.mcpServers)) }, onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation), workingDirectory: plan.workingDirectory?.fsPath, diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts index dbe57c2b591..cdceb65a8b7 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts @@ -19,6 +19,7 @@ import type { AgentsDiscoverRequest } from './copilotRCP.js'; import { AgentCustomization, ChildCustomization, CustomizationLoadStatus, CustomizationType, DirectoryCustomization, RuleCustomization, SkillCustomization, customizationId } from '../../common/state/sessionState.js'; import { ChildCustomizationType } from '../../common/state/protocol/state.js'; import { toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; +import { raceCancellationError } from '../../../../base/common/async.js'; /** * The kinds of customizations the agent host discovers from disk. @@ -248,38 +249,12 @@ export class SessionCustomizationDiscovery extends Disposable { const p: AgentsDiscoverRequest = { projectPaths: [this._workingDirectory.fsPath] }; try { - const agents: AgentCustomization[] = []; - - const agentDiscovery = await client.rpc.agents.discover(p); - for (const agent of agentDiscovery.agents) { - if (agent.path) { - const uri = URI.file(agent.path); - agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); - } - } - - const rules: RuleCustomization[] = []; - - const instructionDiscovery = await client.rpc.instructions.discover(p); - for (const instruction of instructionDiscovery.sources) { - let uri: URI; - if (isAbsolute(instruction.sourcePath)) { - uri = URI.file(instruction.sourcePath); - } else { - uri = joinPath(this._workingDirectory, instruction.sourcePath); - } - rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); - } - - const skills: SkillCustomization[] = []; - - const skillDiscovery = await client.rpc.skills.discover(p); - for (const skill of skillDiscovery.skills) { - if (skill.path) { - const uri = URI.file(skill.path); - skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); - } - } + const [agents, rules, skills] = await Promise.all([ + this.discoverAgents(p, client, token), + this.discoverRules(p, client, token), + this.discoverSkills(p, client, token) + ]); + throwIfCancelled(token); const result: DirectoryCustomization[] = []; this.toDirectoryCustomizations(CustomizationType.Agent, agents, result); @@ -292,6 +267,48 @@ export class SessionCustomizationDiscovery extends Disposable { } } + private async discoverAgents(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const agents: AgentCustomization[] = []; + + const agentDiscovery = await raceCancellationError(client.rpc.agents.discover(discoveryRequest), token); + for (const agent of agentDiscovery.agents) { + if (agent.path) { + const uri = URI.file(agent.path); + agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); + } + } + return agents; + } + + private async discoverRules(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const rules: RuleCustomization[] = []; + + const instructionDiscovery = await raceCancellationError(client.rpc.instructions.discover(discoveryRequest), token); + for (const instruction of instructionDiscovery.sources) { + let uri: URI; + if (isAbsolute(instruction.sourcePath)) { + uri = URI.file(instruction.sourcePath); + } else { + uri = joinPath(this._workingDirectory, instruction.sourcePath); + } + rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); + } + return rules; + } + + private async discoverSkills(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const skills: SkillCustomization[] = []; + + const skillDiscovery = await raceCancellationError(client.rpc.skills.discover(discoveryRequest), token); + for (const skill of skillDiscovery.skills) { + if (skill.path) { + const uri = URI.file(skill.path); + skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); + } + } + return skills; + } + private toDirectoryCustomizations(type: ChildCustomizationType, customizations: readonly ChildCustomization[], result: DirectoryCustomization[]): void { const byParent = new ResourceMap<{ readonly uri: URI; readonly children: ChildCustomization[] }>(); for (const customization of customizations) { @@ -426,11 +443,11 @@ export class SessionCustomizationDiscovery extends Disposable { */ private async _scanFixedDiscoveryFiles(base: URI, roots: IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap, token: CancellationToken): Promise { const filesByType = new Map(); - for (const root of roots) { + await Promise.all(roots.map(async root => { throwIfCancelled(token); if (!await this._watchAncestors(base, root.path, watchRootUris, token)) { - continue; + return; } const rootUri = joinPath(base, ...root.path); @@ -439,10 +456,10 @@ export class SessionCustomizationDiscovery extends Disposable { stat = await this._fileService.resolve(rootUri, { resolveMetadata: true }); } catch { // Root does not exist (or is unreadable) — nothing to discover or watch. - continue; + return; } if (!stat.isDirectory || !stat.children) { - continue; + return; } // Trigger refresh only for the specific filenames this root cares about @@ -463,7 +480,7 @@ export class SessionCustomizationDiscovery extends Disposable { } } } - } + })); for (const [type, files] of filesByType.entries()) { if (files.length > 0) { @@ -492,7 +509,7 @@ export class SessionCustomizationDiscovery extends Disposable { if (root.type === DiscoveredType.Skill) { const files: IDiscoveredFile[] = []; - for (const child of children) { + await Promise.all(children.map(async child => { throwIfCancelled(token); if (child.isDirectory) { @@ -507,7 +524,7 @@ export class SessionCustomizationDiscovery extends Disposable { // SKILL.md missing — skip this skill directory. } } - } + })); result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); } else if (root.type === DiscoveredType.Agent) { const files: IDiscoveredFile[] = []; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index fbaf5e59928..b79b6b362e1 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1140,6 +1140,7 @@ export class ProtocolServerHandler extends Disposable { fork, config: params.config, activeClient: params.activeClient, + progressToken: params.progressToken, }); } catch (err) { if (err instanceof ProtocolError) { diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 4ca135c746e..20206af032f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -11,7 +11,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; -import { buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; +import { buildDefaultChangesetCatalog, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildSubagentSessionUri, SessionStatus, type ISessionFileDiff, type ISessionGitHubState } from '../../common/state/sessionState.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -44,7 +44,7 @@ suite('ChangesetSessionCoordinator', () => { project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }, { emitNotification }); - stateManager.setSessionChangesets(session, buildDefaultChangesetCatalogue(session)); + stateManager.setSessionChangesets(session, buildDefaultChangesetCatalog(session)); stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); } @@ -68,7 +68,7 @@ suite('ChangesetSessionCoordinator', () => { const operationContributionService: IAgentHostChangesetOperationService = { _serviceBrand: undefined, registerContribution: () => Disposable.None, - getOperations: () => undefined, + getOperations: () => [], updateOperations: () => { }, invokeChangesetOperation: async () => ({}), dispose: () => { }, @@ -451,6 +451,7 @@ class TestChangesetService implements IAgentHostChangesetService { restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } persistChangesSummary(_sessionUri: string, _summary: ChangesSummary): void { } isStaticChangesetComputeActive(_changesetUri: string): boolean { return false; } + refreshChangesetCatalog(_session: string): void { } refreshBranchChangeset(session: string): void { this.branchRefreshes.push(session); } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 287465505fb..d865d073288 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; -import { buildBranchChangesetUri, buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; import { ChangesetStatus, SessionStatus, withSessionGitState, type Changeset } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; @@ -77,7 +77,7 @@ suite.skip('AgentHostChangesetService', () => { project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }); - stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } @@ -621,7 +621,7 @@ suite.skip('AgentHostChangesetService', () => { modifiedAt: new Date().toISOString(), workingDirectory, }); - localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalogue(sessionStr)); + localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalog(sessionStr)); return sessionStr; } diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 232953961ea..4473c15e9b9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -110,6 +110,7 @@ class TestChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys(_sessionUri: string): Record | undefined { return undefined; } computeListEntryChanges(_sessionUri: string, _metadata: Record): ChangesSummary | undefined { return undefined; } + refreshChangesetCatalog(session: string): void { this.calls.push(`refreshChangesets:${session}`); } refreshBranchChangeset(session: string): void { this.calls.push(`refreshBranch:${session}`); } refreshSessionChangeset(session: string): void { this.calls.push(`refreshSession:${session}`); } onWorkingDirectoryAvailable(_session: string): void { } diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts index 9503efc85dd..863a3694dbc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts @@ -43,7 +43,7 @@ suite('AgentHostCommitOperationContribution', () => { const operations = provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }); - assert.strictEqual(operations, undefined); + assert.deepStrictEqual(operations?.map(op => op.id), []); }); test('advertises commit on the session changeset when there are uncommitted changes', () => { @@ -51,6 +51,6 @@ suite('AgentHostCommitOperationContribution', () => { const operations = provider.getOperations({ sessionKey, changesetUri: buildSessionChangesetUri(sessionKey), changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }); - assert.deepStrictEqual(operations?.map(op => op.id), undefined); + assert.deepStrictEqual(operations?.map(op => op.id), []); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 50a00273515..93f0ddf2514 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -35,6 +35,7 @@ class FakeChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys() { return undefined; } computeListEntryChanges() { return undefined; } + refreshChangesetCatalog(): void { } refreshBranchChangeset(): void { } refreshSessionChangeset(): void { } onWorkingDirectoryAvailable(): void { } diff --git a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts index e41f93800ce..6daa264734d 100644 --- a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts @@ -19,7 +19,7 @@ import type { IFileService } from '../../../files/common/files.js'; import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { RequestService } from '../../../request/node/requestService.js'; -import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage } from '../../node/agentSdkDownloader.js'; +import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage, type IAgentSdkDownloadProgress } from '../../node/agentSdkDownloader.js'; import { ClaudeSdkPackage } from '../../node/claude/claudeAgentSdkService.js'; import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; import type { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -128,7 +128,7 @@ suite('resolveSdkTarget', () => { ensureNoDisposablesAreLeakedInTestSuite(); function fakePkg(hasSeparateMuslLinuxPackage: boolean): IAgentSdkPackage { - return { id: 'test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; + return { id: 'test', displayName: 'Test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; } test('returns - for supported (platform, arch)', () => { @@ -239,13 +239,13 @@ suite('AgentSdkDownloader', () => { version: productConfig?.version ?? '1.0.0', urlTemplate: productConfig?.urlTemplate ?? `http://127.0.0.1:${server.port}/sdk-{sdkTarget}.tgz`, }; - return new AgentSdkDownloader( + return disposables.add(new AgentSdkDownloader( makeEnvService(userDataPath), makeProductService(config), makeRequestService(disposables), makeFileService(disposables), new NullLogService(), - ); + )); } test('isAvailable: false when no env override and no product config', () => { @@ -280,6 +280,32 @@ suite('AgentSdkDownloader', () => { assert.ok(fs.existsSync(path.join(root, '.complete'))); }); + test('loadSdkRoot: reports monotonic download progress ending at totalBytes', async () => { + const downloader = makeDownloader(); + const samples: IAgentSdkDownloadProgress[] = []; + disposables.add(downloader.onDidDownloadProgress(p => samples.push(p))); + + await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); + + const tarballSize = (await fsp.stat(fixture.tarballPath)).size; + // One `started`, ≥1 `progress`, one terminal `completed`, all sharing a + // single downloadId and carrying the brand display name. + assert.ok(samples.length >= 2, 'expected at least a started and a completed frame'); + assert.strictEqual(samples[0].phase, 'started'); + const completed = samples[samples.length - 1]; + assert.strictEqual(completed.phase, 'completed'); + assert.ok(samples.every(s => s.downloadId === samples[0].downloadId), 'all frames share one downloadId'); + assert.ok(samples.every(s => s.displayName === 'Claude'), 'all frames carry the brand display name'); + + // receivedBytes is monotonically non-decreasing and reaches the total + // reported via Content-Length. + for (let i = 1; i < samples.length; i++) { + assert.ok(samples[i].receivedBytes >= samples[i - 1].receivedBytes, 'receivedBytes must be monotonic'); + } + assert.strictEqual(completed.totalBytes, tarballSize); + assert.strictEqual(completed.receivedBytes, tarballSize); + }); + test('loadSdkRoot: cache hit returns immediately without re-downloading', async () => { const downloader = makeDownloader(); await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 4dc08c7cca9..b3a2b818cc5 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -18,7 +18,7 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; -import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; +import { buildDefaultChangesetCatalog } from '../../common/changesetUri.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; @@ -60,6 +60,7 @@ class FakeChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys(_sessionUri: string): Record | undefined { return undefined; } computeListEntryChanges(_sessionUri: string, _metadata: Record): ChangesSummary | undefined { return undefined; } + refreshChangesetCatalog(session: string): void { /* no-op */ } refreshBranchChangeset(): void { /* no-op */ } refreshSessionChangeset(): void { /* no-op */ } onWorkingDirectoryAvailable(): void { /* no-op */ } @@ -156,7 +157,7 @@ suite('AgentSideEffects', () => { project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }); - stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index ffc6312df94..af9afedf579 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -356,6 +356,10 @@ class ProxyRoundTripSdkService implements IClaudeAgentSdkService { return []; } + async canLoadWithoutDownload(): Promise { + return true; + } + async getSessionInfo(_sessionId: string): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 6bd3a8835c2..e348329fe71 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -236,6 +236,10 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { return this.sessionList; } + async canLoadWithoutDownload(): Promise { + return true; + } + /** * Fake for {@link IClaudeAgentSdkService.getSessionInfo}. Tests stage * `sessionList` and the fake searches it by id; setting @@ -788,7 +792,9 @@ function forkSourceMessages(sourceId: string): SessionMessage[] { function stubAgentSdkDownloader(): IAgentSdkDownloader { return { _serviceBrand: undefined, + onDidDownloadProgress: Event.None, isAvailable: () => false, + isSdkResolvableWithoutDownload: async () => false, loadSdkRoot: () => { throw new Error('test stub: downloader.loadSdkRoot should not be called'); }, }; } diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts index f1ac66bd442..378cd8319a0 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts @@ -42,6 +42,7 @@ class FakeSdkService implements IClaudeAgentSdkService { getSubagentMessagesCalls: { sessionId: string; agentId: string }[] = []; async listSessions(): Promise { return []; } + async canLoadWithoutDownload(): Promise { return true; } async getSessionInfo(_id: string): Promise { return undefined; } async startup(_p: { options: Options; initializeTimeoutMs?: number }): Promise { throw new Error('not used'); } async query(_params: { prompt: string | AsyncIterable; options?: Options }): Promise { throw new Error('not used'); } diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts index b7c494974a6..270ca1d13ed 100644 --- a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts @@ -23,12 +23,12 @@ */ import assert from 'assert'; -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; -import { MessageAttachmentKind, buildDefaultChatUri, ToolCallConfirmationReason, type MessageAttachment } from '../../../common/state/sessionState.js'; -import { ActionType, type ChatUsageAction } from '../../../common/state/sessionActions.js'; +import { CustomizationType, MessageAttachmentKind, buildDefaultChatUri, ToolCallConfirmationReason, type DirectoryCustomization, type MessageAttachment } from '../../../common/state/sessionState.js'; +import { ActionType, SessionCustomizationsChangedAction, type ChatUsageAction } from '../../../common/state/sessionActions.js'; import { createRealSession, defineSharedRealSdkTests, dispatchTurn, driveTurnWithAttachmentsToCompletion, type IRealSdkProviderConfig, @@ -164,6 +164,85 @@ defineSharedRealSdkTests(COPILOT_CONFIG); assert.match(result.responseText, /\bsubtract\b/i, `expected the model to identify the attached blob function; got: ${JSON.stringify(result.responseText)}`); }); + test('detects workspace agents, instructions, skills, and hooks via session/customizationsChanged after hello', async function () { + this.timeout(180_000); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-test-`); + tempDirs.push(workspaceDir); + const githubDir = join(workspaceDir, '.github'); + const agentsDir = join(githubDir, 'agents'); + const instructionsDir = join(githubDir, 'instructions'); + const skillsDir = join(githubDir, 'skills', 'hello-skill'); + const hooksDir = join(githubDir, 'hooks'); + + await Promise.all([ + mkdir(agentsDir, { recursive: true }), + mkdir(instructionsDir, { recursive: true }), + mkdir(skillsDir, { recursive: true }), + mkdir(hooksDir, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(agentsDir, 'hello.agent.md'), [ + '---', + 'name: Hello Agent', + 'description: Handles hello requests', + '---', + 'You are a test agent.', + ].join('\n')), + writeFile(join(instructionsDir, 'policy.instructions.md'), [ + '---', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')), + writeFile(join(skillsDir, 'SKILL.md'), [ + '---', + 'name: Hello Skill', + 'description: Says hello', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(join(hooksDir, 'pre-tool.json'), JSON.stringify({ PreToolUse: [] }, undefined, 2)), + ]); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations', createdSessions, URI.file(workspaceDir).toString()); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-client', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations', 'hello', 2); + + const [customizationsNotif] = await Promise.all([ + client.waitForNotification(n => isActionNotification(n, ActionType.SessionCustomizationsChanged) && getActionEnvelope(n).channel === sessionUri, 120_000), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete') && getActionEnvelope(n).channel === buildDefaultChatUri(sessionUri), 120_000), + ]); + + const customizationsAction = getActionEnvelope(customizationsNotif).action as SessionCustomizationsChangedAction; + const directories = customizationsAction.customizations.filter((customization): customization is DirectoryCustomization => customization.type === CustomizationType.Directory); + const expectChildType = (directoryUri: string, expectedType: CustomizationType, expectedName: string): void => { + const directory = directories.find(customization => customization.uri === directoryUri); + assert.ok(directory, `expected discovered directory ${directoryUri}`); + const matchingChildren = directory.children?.filter(child => child.type === expectedType && child.name === expectedName) ?? []; + assert.ok( + matchingChildren.length === 1, + `expected ${directoryUri} to contain a ${expectedType} customization with name ${expectedName}; got: ${JSON.stringify(directory.children)}`, + ); + }; + expectChildType(URI.file(agentsDir).toString(), CustomizationType.Agent, 'Hello Agent'); + expectChildType(URI.file(instructionsDir).toString(), CustomizationType.Rule, 'policy'); + expectChildType(URI.file(join(githubDir, 'skills')).toString(), CustomizationType.Skill, 'Hello Skill'); + expectChildType(URI.file(hooksDir).toString(), CustomizationType.Hook, 'pre-tool.json'); + }); + test('strips redundant `cd &&` prefix from shell tool calls', async function () { this.timeout(180_000); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 7687a04c0c6..896750c35fd 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -13,7 +13,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../../common/state/sessionActions.js'; +import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ProgressParams } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, type SessionSummary } from '../../common/state/sessionState.js'; @@ -1990,6 +1990,70 @@ suite('ProtocolServerHandler', () => { }); }); + suite('download progress channel', () => { + // Progress is emitted on the state manager (so it reaches both local + // IPC and remote WebSocket renderers through the same path as session + // notifications). This suite verifies the handler forwards each frame to + // connected clients as a `progress` notification on the root channel. + // Spun up per-test with a private state manager so the outer suite is + // unaffected. + let dlStateManager: AgentHostStateManager; + let dlServer: MockProtocolServer; + let dlAgentService: MockAgentService; + let localDisposables: DisposableStore; + + setup(() => { + localDisposables = new DisposableStore(); + dlStateManager = localDisposables.add(new AgentHostStateManager(new NullLogService())); + dlServer = localDisposables.add(new MockProtocolServer()); + dlAgentService = new MockAgentService(); + dlAgentService.setStateManager(dlStateManager); + localDisposables.add(dlAgentService); + localDisposables.add(new ProtocolServerHandler( + dlAgentService, + dlStateManager, + dlServer, + { defaultDirectory: URI.file('/home/testuser').toString() }, + localDisposables.add(new AgentHostFileSystemProvider()), + new NullLogService(), + )); + }); + + teardown(() => { + localDisposables.dispose(); + }); + + function connectDownloadClient(clientId: string): MockProtocolTransport { + const transport = new MockProtocolTransport(); + dlServer.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId, + })); + return transport; + } + + function findProgress(sent: ProtocolMessage[]): ProgressParams[] { + return sent + .filter(isJsonRpcNotification) + .filter((m): m is AhpNotification & { method: 'root/progress'; params: ProgressParams } => m.method === 'root/progress') + .map(m => m.params); + } + + test('forwards each progress frame to connected clients on the root channel', () => { + const transport = connectDownloadClient('client-dl-1'); + + dlStateManager.emitProgress({ progressToken: 't1', progress: 0, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 500, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 1000, total: 1000, message: 'Claude' }); + + const frames = findProgress(transport.sent); + assert.deepStrictEqual(frames.map(f => f.progress), [0, 500, 1000]); + assert.ok(frames.every(f => f.progressToken === 't1' && f.message === 'Claude' && f.total === 1000)); + assert.ok(frames.every(f => (f as ProgressParams & { channel: string }).channel === 'ahp-root://'), 'frames are broadcast on the root channel'); + }); + }); + suite('resource watches', () => { test('subscribe to a resource-watch channel returns the descriptor + bumps refcount; envelopes are routed', async () => { diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index da4c20e34d0..b7f963076bb 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -59,6 +59,9 @@ export class BrowserView extends Disposable { private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; + private _wantsVisibility = false; + private _hasBeenLaidOut = false; + private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; private readonly _consoleLogs: string[] = []; @@ -147,7 +150,9 @@ export class BrowserView extends Disposable { }); // Use a default size of 1024x768. - this._view.setBounds({ x: -10000, y: -10000, width: 1024, height: 768 }); + // Important: The bounds here must be on-screen, otherwise some OSes (like macOS) may not actually start rendering. + // We just have to be careful to not show the view until a layout has happened in the correct location. + this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 }); this._view.setBackgroundColor('#FFFFFF'); this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; @@ -637,6 +642,11 @@ export class BrowserView extends Disposable { width: Math.round(bounds.width * bounds.zoomFactor), height: Math.round(bounds.height * bounds.zoomFactor) }); + + this._hasBeenLaidOut = true; + if (this._wantsVisibility && !this._view.getVisible()) { + this._view.setVisible(true); + } } setBrowserZoomIndex(zoomIndex: number): void { @@ -649,7 +659,7 @@ export class BrowserView extends Disposable { * Set the visibility of this view */ setVisible(visible: boolean): void { - if (this._view.getVisible() === visible) { + if (this._wantsVisibility === visible) { return; } @@ -658,7 +668,11 @@ export class BrowserView extends Disposable { this._currentWindow?.win?.webContents.focus(); } - this._view.setVisible(visible); + if (this._hasBeenLaidOut || !visible) { + this._view.setVisible(visible); + } + + this._wantsVisibility = visible; this._onDidChangeVisibility.fire({ visible }); } @@ -762,9 +776,29 @@ export class BrowserView extends Disposable { if (options?.awaitNextPaint) { await this._waitForNextPaint(); } - const image = await this._view.webContents.capturePage(options?.screenRect, { - stayHidden: true - }); + const image = await (async () => { + const maxAttempts = 5; + let lastError: Error | undefined; + for (let i = 0; i < maxAttempts; i++) { + try { + return await this._view.webContents.capturePage(options?.screenRect, { + stayHidden: true + }); + } catch (error) { + // `UnknownVizError` is a transient Electron error when no frame is available yet + // (e.g. offscreen scenarios where rendering has just been kicked off by `setVisible(true)`), + // so retry a few times. + if (error instanceof Error && error.message === 'UnknownVizError') { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 16)); + continue; + } else { + throw error; + } + } + } + throw new Error(`Failed to capture screenshot after ${maxAttempts} attempts`, { cause: lastError }); + })(); const buffer = format === 'png' ? image.toPNG() : image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); // Only update _lastScreenshot if capturing the full view diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index a0b91f1c5c7..69b02bc6d17 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -93,6 +93,31 @@ export function managedSettingValue(key: string): (policyData: IPolicyData) => M return callback; } +let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined; + +/** + * `value` callback for the default-chat-model managed setting ({@link COPILOT_MODEL_KEY}). Like + * {@link managedSettingValue} it locks the setting to the managed value and otherwise falls through + * to the user's own value, but it additionally trims the string and treats a blank/whitespace-only + * value as "unset" (returns `undefined`) — an admin clearing the field must not lock the setting to + * an empty string. The model-specific normalization lives here, alongside the other managed-settings + * handling, rather than inline at the policy declaration, so every managed-settings control is wired + * the same way. + * + * Memoized (single key) so repeated calls return the SAME function reference, matching the + * reference-identity contract {@link managedSettingValue} relies on for `isSamePolicyDefinition`. + */ +export function managedModelValue(): (policyData: IPolicyData) => ManagedSettingValue | undefined { + if (!managedModelValueCallback) { + managedModelValueCallback = policyData => { + const model = policyData.managedSettings?.[COPILOT_MODEL_KEY]; + const trimmed = typeof model === 'string' ? model.trim() : undefined; + return trimmed ? trimmed : undefined; + }; + } + return managedModelValueCallback; +} + export const INativeManagedSettingsService = createDecorator('nativeManagedSettingsService'); export interface INativeManagedSettingsService { @@ -222,20 +247,20 @@ export interface IManagedSettingsSelection { /** * Select the authoritative managed-settings bag from the available delivery channels. * - * Precedence (highest first): server-delivered → native MDM → file on disk. The channels are + * Precedence (highest first): native MDM → server-delivered → file on disk. The channels are * never merged — managed settings have a single authoritative source, so the first non-empty bag - * wins outright. Centralizing the precedence here (rather than inlining it at each call site) - * keeps policy evaluation ({@link AccountPolicyService.getPolicyData}) and the Policy Diagnostics - * report from drifting apart, and gives one obvious place to extend when a new channel is - * introduced. + * wins outright. The parameter order matches that precedence so call sites read top-to-bottom. + * Centralizing the precedence here (rather than inlining it at each call site) keeps policy + * evaluation ({@link AccountPolicyService.getPolicyData}) and the Policy Diagnostics report from + * drifting apart, and gives one obvious place to extend when a new channel is introduced. */ -export function selectManagedSettings(server: ManagedSettingsData | undefined, nativeMdm: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsSelection { - if (server && !isEmptyObject(server)) { - return { source: 'server', values: server }; - } +export function selectManagedSettings(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsSelection { if (nativeMdm && !isEmptyObject(nativeMdm)) { return { source: 'nativeMdm', values: nativeMdm }; } + if (server && !isEmptyObject(server)) { + return { source: 'server', values: server }; + } if (file && !isEmptyObject(file)) { return { source: 'file', values: file }; } @@ -301,18 +326,37 @@ function encodeStringMap(value: unknown): Record | undefined { return out; } +/** Pass an object value through unchanged; omit the key for any non-object value. */ +function encodeObject(value: unknown): object | undefined { + return isObject(value) ? value : undefined; +} + +/** Pass an array value through unchanged (including an empty array); omit the key otherwise. */ +function encodeArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +/** + * Encode the schema's `{ [id]: { source } }` marketplace map into the canonical + * `{ [name]: url-or-shorthand }` dict; drops malformed entries (with an optional warning) and omits + * the key when there are none. + */ +function encodeExtraMarketplaces(value: unknown, onWarn?: (msg: string) => void): Record | undefined { + return extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)); +} + const STRUCTURED_MANAGED_SETTINGS: readonly IStructuredManagedSetting[] = [ { key: COPILOT_ENABLED_PLUGINS_KEY, - encode: value => isObject(value) ? value : undefined, + encode: encodeObject, }, { key: COPILOT_STRICT_MARKETPLACES_KEY, - encode: value => Array.isArray(value) ? value : undefined, + encode: encodeArray, }, { key: COPILOT_EXTRA_MARKETPLACES_KEY, - encode: (value, onWarn) => extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)), + encode: encodeExtraMarketplaces, }, { // Nested under `telemetry`; carried as a JSON-encoded `{ [k]: string }` map. Non-string diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index 83755700f39..66b9ddb04e6 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -108,23 +108,23 @@ suite('Copilot managed settings projection', () => { assert.strictEqual(warnings.length, 1); }); - test('selectManagedSettings: server wins over native MDM and file, never merged', () => { + test('selectManagedSettings: native MDM wins over server and file, never merged', () => { const selection = selectManagedSettings( - { 'permissions.x': 'server' }, { 'permissions.y': 'native' }, + { 'permissions.x': 'server' }, { 'permissions.z': 'file' }, ); - assert.deepStrictEqual(selection, { source: 'server', values: { 'permissions.x': 'server' } }); + assert.deepStrictEqual(selection, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); }); - test('selectManagedSettings: falls through to native MDM when server is absent or empty', () => { - const fromUndefined = selectManagedSettings(undefined, { 'permissions.y': 'native' }, undefined); - const fromEmptyObject = selectManagedSettings({}, { 'permissions.y': 'native' }, undefined); - assert.deepStrictEqual(fromUndefined, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); - assert.deepStrictEqual(fromEmptyObject, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); + test('selectManagedSettings: falls through to server when native MDM is absent or empty', () => { + const fromUndefined = selectManagedSettings(undefined, { 'permissions.x': 'server' }, undefined); + const fromEmptyObject = selectManagedSettings({}, { 'permissions.x': 'server' }, undefined); + assert.deepStrictEqual(fromUndefined, { source: 'server', values: { 'permissions.x': 'server' } }); + assert.deepStrictEqual(fromEmptyObject, { source: 'server', values: { 'permissions.x': 'server' } }); }); - test('selectManagedSettings: falls through to file when server and native MDM are absent or empty', () => { + test('selectManagedSettings: falls through to file when native MDM and server are absent or empty', () => { const fromUndefined = selectManagedSettings(undefined, undefined, { 'permissions.z': 'file' }); const fromEmptyObjects = selectManagedSettings({}, {}, { 'permissions.z': 'file' }); assert.deepStrictEqual(fromUndefined, { source: 'file', values: { 'permissions.z': 'file' } }); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index f0c7bf5783b..f583da885a9 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -124,8 +124,8 @@ 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 (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind — plus the workspace label, and a hover showing the working-directory path and git branch, registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; 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 tracks the number of **open chats**: it is shown as soon as the session has more than one open chat (**including in-composer draft chats**) and hidden again when chats are removed back down to just the main chat. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single open chat); once the strip is shown (more than one open chat, including drafts) the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionHasMultipleOpenChatsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. +- 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 (the contributed workspace folder / changes / pull request / terminals buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind — plus the workspace label, and a hover showing the working-directory path and git branch, registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; 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. The sessions terminal contribution similarly contributes a terminals button (order 2, so it follows the pull request button) showing the terminal icon + `{n} terminals` (gated by the per-view `SessionHasTerminalsContext` key, which `SessionView` sets from the terminal counts exposed by `ISessionTerminalsService` — backed by `SessionsTerminalContribution`). The label counts the session's terminals that have had at least one command sent in them (empty terminals that never ran a command are excluded), while the hover reports how many of those are currently running something (active) via `ITerminalInstance.hasChildProcesses` — e.g. a watch task or an in-progress `npm install`. The contribution records "has had a command" stickily per terminal (from executed text, command detection or a started child process) and recomputes the active subset live; the custom action view item extends `SessionHeaderMetaActionViewItem` to render the live count and the `{n} active terminals` hover, registered from `contrib/terminal/browser/terminalMetaActions.ts`) that, when activated, reveals the terminal view 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 **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **open chats**: it is shown as soon as the session has more than one open chat (**including in-composer draft chats**) and hidden again when chats are removed back down to just the main chat. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single open chat); once the strip is shown (more than one open chat, including drafts) the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionHasMultipleOpenChatsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. While the tab strip is shown the chat tabs are keyboard-navigable from the active session: `Ctrl/Cmd+Shift+]` / `Ctrl/Cmd+Shift+[` go to the next / previous chat (wrapping), `Ctrl/Cmd+W` closes the active chat tab (deleting an in-composer draft, hiding a committed chat) instead of the session — the same command (`sessions.chatCompositeBar.closeChat`) is contributed to the per-tab `Menus.SessionChatTab`, which the chat tab strip renders as each non-main tab's close button (forwarding the tab's chat as the action argument), and `Ctrl+Tab` / `Ctrl+Shift+Tab` open a **chat switcher** — a no-input, editor-switcher (MRU) quick pick over the session's **open** chats (skipping in-composer drafts), each shown with a chat icon (hold the modifier, press `Tab` to cycle, release to select), winning over the session-history secondary on that chord while the session has multiple open chats and falling back to session navigation otherwise (and to the editor's own `Ctrl+Tab` switcher while a quick pick is already open, since the open chords are gated on `inQuickOpen` negated); the **Go to Chat in Session** palette command (`sessions.showChatsPicker`, `Ctrl/Cmd+Shift+O`, gated on more than one committed chat) opens a **searchable** variant that additionally lists **Closed** chats in a separate group (selecting one reopens it) — these commands (`sessions.chatCompositeBar.navigateNextChat` / `navigatePreviousChat` / `closeChat` and `sessions.showChatsPicker` in `contrib/sessions/browser/sessionsActions.ts`) outrank the session-level navigation/close chords via a higher keybinding weight and are gated on `SessionHasMultipleOpenChatsContext` / `SessionActiveChatIsClosableContext`. - 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/menus.ts b/src/vs/sessions/browser/menus.ts index cdf13f32672..1606b7e7fed 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -37,6 +37,7 @@ export const Menus = { SessionWorkspaceManage: new MenuId('Sessions.SessionWorkspaceManage'), SessionBarToolbar: new MenuId('SessionsSessionBarToolbar'), SessionConversations: new MenuId('SessionsSessionConversations'), + SessionChatTab: new MenuId('SessionsSessionChatTab'), SessionChatTabBar: new MenuId('SessionsSessionChatTabBar'), SessionHeaderMeta: new MenuId('SessionsSessionHeaderMeta'), SessionHeaderContext: MenuId.SessionHeaderContext, diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 662ad3c1b12..cb68ad6c07d 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -26,7 +26,7 @@ import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { localize } from '../../../nls.js'; -import { ChatOriginKind, IChat, SessionStatus } from '../../services/sessions/common/session.js'; +import { IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; @@ -203,18 +203,17 @@ export class ChatCompositeBar extends Disposable { // chat. The strip's own trailing "New Chat" action follows this visibility. this._setVisible(false); store.add(autorun(reader => { - const openChats = session.openChats.read(reader); const mainChat = session.mainChat.read(reader); const activeChatUri = session.activeChat.read(reader)?.resource.toString() ?? ''; const mainChatUri = mainChat.resource.toString(); - const visibleOpenChats = openChats.filter(chat => chat.origin?.kind !== ChatOriginKind.Tool); - this._rebuildTabs(visibleOpenChats, activeChatUri, mainChatUri); + const tabs = session.visibleChatTabs.read(reader); + this._rebuildTabs(tabs, activeChatUri, mainChatUri); // Archived sessions are read-only, so disable the trailing New Chat // action (mirrors the header action's SessionIsArchivedContext gating). this._newChatAction.enabled = !session.isArchived.read(reader); - this._setVisible(session.isCreated.read(reader) && visibleOpenChats.length > 1); + this._setVisible(session.isCreated.read(reader) && tabs.length > 1); })); } @@ -304,33 +303,19 @@ export class ChatCompositeBar extends Disposable { tab.appendChild(indicator); - // Close action — only for non-main chats, always visible. For a committed - // chat, closing hides it from the tab strip (reopenable from the chats - // dropdown in the session header); use Delete to remove it permanently. For - // an untitled (in-composer) draft chat there is nothing to reopen, so the - // action deletes the draft outright (no confirmation) and is labelled - // accordingly so keyboard/screen-reader users know it is destructive. - if (!isMainChat) { - const isDraft = chat.status.get() === SessionStatus.Untitled; - const closeAction = this._tabDisposables.add(new Action( - 'chatCompositeBar.closeChat', - isDraft ? localize('deleteDraftChat', "Delete Chat") : localize('closeChat', "Close"), - ThemeIcon.asClassName(Codicon.close), - true, - async () => { - if (!this._session) { - return; - } - if (chat.status.get() === SessionStatus.Untitled) { - await this._sessionsManagementService.deleteChat(this._session, chat.resource, { skipConfirmation: true }); - } else { - await this._sessionsService.closeChat(this._session, chat); - } - }, - )); - const actionBar = this._tabDisposables.add(new ActionBar(tab, { actionViewItemProvider: undefined })); - actionBar.push(closeAction, { icon: true, label: false }); - actionBar.getContainer().classList.add('chat-composite-bar-tab-actions'); + // Close button — contributed via Menus.SessionChatTab (the chat tab menu). + // Only non-main chats can be closed; the main chat lives and dies with its + // session, so its tab renders no actions toolbar. The tab's chat (and its + // session) is forwarded as the action argument. + if (!isMainChat && session) { + const actionsContainer = $('.chat-composite-bar-tab-actions'); + tab.appendChild(actionsContainer); + const tabToolbar = this._tabDisposables.add(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, Menus.SessionChatTab, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + menuOptions: { shouldForwardArgs: true }, + toolbarOptions: { primaryGroup: () => true }, + })); + tabToolbar.context = { session, chat }; } this._tabsContainer.appendChild(tab); diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 2f29e92fca4..9bab5d538b2 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -19,9 +19,10 @@ import { AbstractChatView, ChatViewKind, IChatViewOptions } from './chatView.js' 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 { SessionIsMaximizedContext } from '../../common/contextkeys.js'; +import { autorun, observableValue, observableSignalFromEvent } from '../../../base/common/observable.js'; +import { SessionIsMaximizedContext, SessionHasTerminalsContext } from '../../common/contextkeys.js'; import { setActiveSessionContextKeys } from '../../services/sessions/common/sessionContextKeys.js'; +import { ISessionTerminalsService } from '../../services/sessions/browser/sessionTerminalsService.js'; import { activeSessionViewBackground, activeSessionViewForeground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../common/theme.js'; import { SessionStatus } from '../../services/sessions/common/session.js'; @@ -84,6 +85,7 @@ export class SessionView extends Disposable implements ISerializableView { @IChatViewFactory private readonly chatViewFactory: IChatViewFactory, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, + @ISessionTerminalsService sessionTerminalsService: ISessionTerminalsService, ) { super(); @@ -92,6 +94,20 @@ export class SessionView extends Disposable implements ISerializableView { const scopedContextKeyService = this._scopedContextKeyService = this._register(contextKeyService.createScoped(this.element)); this._sessionIsMaximizedKey = SessionIsMaximizedContext.bindTo(scopedContextKeyService); + // The terminal counts are owned by the terminal contribution rather than + // the session model, so they are tracked here with a dedicated autorun + // (the session-model context keys are applied separately per opened + // session). The pill is shown once the session has at least one terminal + // that has had a command sent in it. + const hasTerminalsKey = SessionHasTerminalsContext.bindTo(scopedContextKeyService); + const terminalsSignal = observableSignalFromEvent(this, sessionTerminalsService.onDidChangeTerminals); + this._register(autorun(reader => { + terminalsSignal.read(reader); + const session = this._sessionObs.read(reader); + const total = session ? sessionTerminalsService.getTerminalCounts(session.sessionId).total : 0; + hasTerminalsKey.set(total > 0); + })); + // 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. const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection( diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 9a03c5bab9f..65d0d847ae2 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -29,11 +29,13 @@ export const SessionIsMaximizedContext = new RawContextKey('sessionIsMa export const SessionSupportsMultipleChatsContext = new RawContextKey('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Conversations menu visibility")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat, i.e. the chat tab strip is shown. Used to hide the header New Chat button, which the tab strip then offers instead")); +export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed/deleted from the tab strip (i.e. it is not the main chat). Used to scope the close-chat keybinding")); 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 in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); 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")); export const SessionHasWorkspaceContext = new RawContextKey('sessionHasWorkspace', false, localize('sessionHasWorkspace', "Whether the session view's session has an associated workspace folder")); +export const SessionHasTerminalsContext = new RawContextKey('sessionHasTerminals', false, localize('sessionHasTerminals', "Whether the session view's session has one or more terminals that have had at least one command sent in them")); //#endregion @@ -75,6 +77,7 @@ export const SessionIsolationPickerVisibleContext = new RawContextKey(' //#region < --- Sessions Picker --- > export const SessionsPickerVisibleContext = new RawContextKey('sessionsPickerVisible', false, localize('sessionsPickerVisible', "Whether the sessions picker is visible")); +export const SessionChatsPickerVisibleContext = new RawContextKey('sessionChatsPickerVisible', false, localize('sessionChatsPickerVisible', "Whether the chats picker (chats within the active session) is visible")); //#endregion diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 22e67942c21..0ed2a08d2b3 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -571,6 +571,12 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements // selection and opens the input for the freshly selected line. this._editor.setSelection(new Selection(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber))); this._editor.focus(); + + // Focusing the editor synchronously opens the input via the + // selection-change handler, so move focus into it now that it is + // visible. This lets the user type feedback immediately after clicking + // the gutter glyph without having to click the input first. + this.focusInput(); } private _getSessionForModel(): ISession | undefined { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 460ea425081..4596ec52f1b 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -35,7 +35,7 @@ class SubmitFeedbackActionRunner extends ActionRunner { protected override async runAction(action: IAction, context?: unknown): Promise { const editorToClose = action.id === submitFeedbackActionId ? this._editorGroup.activeEditor : undefined; const didSubmit = await action.run(context); - if (didSubmit === true && editorToClose && this._editorGroup.contains(editorToClose)) { + if (didSubmit === true && editorToClose) { await this._editorGroup.closeEditor(editorToClose); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 773968080fa..7028d35b068 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -27,6 +27,7 @@ import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider import { AnnotationsAgentFeedbackItemsBackend, IAgentFeedbackItemsBackend, InMemoryAgentFeedbackItemsBackend } from './agentFeedbackItemsBackend.js'; import { ATTACHMENT_ID_PREFIX, createAgentFeedbackVariableEntry } from './agentFeedbackAttachmentEntry.js'; import { AgentFeedbackKind, AgentFeedbackState, type IAgentFeedback } from './agentFeedbackModel.js'; +import { SessionEditorCommentSource, toSessionEditorCommentId } from './sessionEditorComments.js'; // --- Types -------------------------------------------------------------------- @@ -549,7 +550,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe if (!feedback) { return; } - await this.revealSessionComment(sessionResource, feedbackId, feedback.resourceUri, feedback.range); + // Anchor using the session-editor-comment id (not the raw feedback id) so the editor widget contribution matches the active item and expands its widget. + await this.revealSessionComment(sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedbackId), feedback.resourceUri, feedback.range); } async revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts index 5624f43a545..e4bce498f03 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -5,7 +5,7 @@ import { IRange, Range } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; -import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback } from './agentFeedbackService.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback } from './agentFeedbackModel.js'; import { ICodeReviewSuggestion, IPRReviewComment, IPRReviewState, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; export const enum SessionEditorCommentSource { diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 1b5353990de..dc806720b68 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -12,6 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { AgentFeedbackKind, AgentFeedbackService, AgentFeedbackState, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { getSessionEditorComments } from '../../browser/sessionEditorComments.js'; import { IChatEditingService } from '../../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentFeedbackVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -51,6 +52,7 @@ suite('AgentFeedbackService - Ordering', () => { instantiationService.stub(IEditorService, new class extends mock() { override onDidVisibleEditorsChange = Event.None; override visibleEditorPanes = []; + override openEditor(..._args: unknown[]): Promise { return Promise.resolve(undefined); } }); instantiationService.stub(ISessionsManagementService, new class extends mock() { override getSession(_resource: URI) { return undefined; } @@ -191,6 +193,25 @@ suite('AgentFeedbackService - Ordering', () => { assert.strictEqual(bearing.activeIdx, 2); }); + test('revealFeedback anchors the matching session editor comment so its widget expands', async () => { + const f1 = service.addFeedback(session, fileA, r(5), 'A:5'); + const f2 = service.addFeedback(session, fileA, r(20), 'A:20'); + + // The editor widget contribution expands the widget whose session + // editor comment matches the navigation anchor. revealFeedback must set + // the anchor using the prefixed session-editor-comment id (not the raw + // feedback id) for that match to succeed. + await service.revealFeedback(session, f2.id); + + const comments = getSessionEditorComments(session, service.getFeedback(session)); + const bearing = service.getNavigationBearing(session, comments); + assert.strictEqual(comments[bearing.activeIdx]?.sourceId, f2.id); + + await service.revealFeedback(session, f1.id); + const bearingAfter = service.getNavigationBearing(session, comments); + assert.strictEqual(comments[bearingAfter.activeIdx]?.sourceId, f1.id); + }); + test('removing feedback preserves ordering', () => { const f1 = service.addFeedback(session, fileA, r(30), 'A:30'); service.addFeedback(session, fileA, r(10), 'A:10'); diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index b49834b133c..a1bab2f6441 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -21,9 +21,13 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ChangesViewService } from './changesViewService.js'; import { IChangesViewService } from '../common/changesViewService.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { SessionsChangesAccessibilityHelp } from './sessionsChangesAccessibilityHelp.js'; registerSingleton(ISessionChangesService, SessionChangesService, InstantiationType.Delayed); +AccessibleViewRegistry.register(new SessionsChangesAccessibilityHelp()); + const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index c761ffb4975..cdc2650bb0e 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -59,6 +59,8 @@ import { getChangesEditorLabels } from './changesEditorLabels.js'; import { ISessionChangesService } from './sessionChangesService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { CIStatusWidget } from './checksWidget.js'; +import { SessionFilesWidget } from './sessionFilesWidget.js'; +import { SessionFilesViewModel } from './sessionFilesViewModel.js'; import { GITHUB_REMOTE_FILE_SCHEME, ISessionChangesetOperation, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus } from '../../../services/sessions/common/session.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; @@ -363,6 +365,7 @@ export class ChangesViewPane extends ViewPane { private changesProgressBar!: ProgressBar; private tree: WorkbenchCompressibleObjectTree | undefined; private ciStatusWidget: CIStatusWidget | undefined; + private sessionFilesWidget: SessionFilesWidget | undefined; private splitView: SplitView | undefined; private splitViewContainer: HTMLElement | undefined; @@ -537,6 +540,9 @@ export class ChangesViewPane extends ViewPane { const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here."); + // Session Files widget — middle pane (files edited outside the workspace) + this.sessionFilesWidget = this._register(this.scopedInstantiationService.createInstance(SessionFilesWidget, this.splitViewContainer)); + // CI Status widget — bottom pane this.ciStatusWidget = this._register(this.scopedInstantiationService.createInstance(CIStatusWidget, this.splitViewContainer)); @@ -548,6 +554,7 @@ export class ChangesViewPane extends ViewPane { // Shared constants for pane sizing const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT; + const sessionFilesMinHeight = SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.MIN_BODY_HEIGHT; const treeMinHeight = 3 * ChangesTreeDelegate.ROW_HEIGHT; // Top pane: file tree @@ -562,6 +569,21 @@ export class ChangesViewPane extends ViewPane { }, }; + // Middle pane: session files + const sessionFilesElement = this.sessionFilesWidget.element; + const sessionFilesWidget = this.sessionFilesWidget; + const sessionFilesPane: IView = { + element: sessionFilesElement, + get minimumSize() { return sessionFilesWidget.collapsed ? SessionFilesWidget.HEADER_HEIGHT : sessionFilesMinHeight; }, + get maximumSize() { return sessionFilesWidget.collapsed ? SessionFilesWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; }, + onDidChange: Event.map(this.sessionFilesWidget.onDidChangeHeight, () => undefined), + layout: (height) => { + sessionFilesElement.style.height = `${height}px`; + const bodyHeight = Math.max(0, height - SessionFilesWidget.HEADER_HEIGHT); + sessionFilesWidget.layout(bodyHeight); + }, + }; + // Bottom pane: CI checks const ciElement = this.ciStatusWidget.element; const ciWidget = this.ciStatusWidget; @@ -578,7 +600,8 @@ export class ChangesViewPane extends ViewPane { }; this.splitView.addView(treePane, Sizing.Distribute, 0, true); - this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT, 1, true); + this.splitView.addView(sessionFilesPane, SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT, 1, true); + this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT, 2, true); // Style the sash as a visible separator between sections const updateSplitViewStyles = () => { @@ -588,39 +611,15 @@ export class ChangesViewPane extends ViewPane { updateSplitViewStyles(); this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles)); - // Initially hide CI pane until checks arrive + // Initially hide the session files and CI panes until content arrives this.splitView.setViewVisible(1, false); + this.splitView.setViewVisible(2, false); - let savedCIPaneHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT; - this._register(this.ciStatusWidget.onDidToggleCollapsed(collapsed => { - if (!this.splitView || !this.ciStatusWidget) { - return; - } - if (collapsed) { - // Save current size before collapsing - const currentSize = this.splitView.getViewSize(1); - if (currentSize > CIStatusWidget.HEADER_HEIGHT) { - savedCIPaneHeight = currentSize; - } - this.splitView.resizeView(1, CIStatusWidget.HEADER_HEIGHT); - } else { - // Restore saved size on expand - this.splitView.resizeView(1, savedCIPaneHeight); - } - this.layoutSplitView(); - })); + // Session files pane (index 1) + this._wireSectionPane(this.sessionFilesWidget, 1, SessionFilesWidget.HEADER_HEIGHT, SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT); - this._register(this.ciStatusWidget.onDidChangeHeight(() => { - if (!this.splitView || !this.ciStatusWidget) { - return; - } - const visible = this.ciStatusWidget.visible; - const isCurrentlyVisible = this.splitView.isViewVisible(1); - if (visible !== isCurrentlyVisible) { - this.splitView.setViewVisible(1, visible); - } - this.layoutSplitView(); - })); + // CI checks pane (index 2) + this._wireSectionPane(this.ciStatusWidget, 2, CIStatusWidget.HEADER_HEIGHT, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT); this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { @@ -793,6 +792,14 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(this.ciStatusWidget.setInput(checksViewModel)); } + // Session files (files edited outside the workspace during the session) + if (this.sessionFilesWidget) { + const sessionFilesViewModel = this.scopedInstantiationService.createInstance(SessionFilesViewModel); + this.renderDisposables.add(sessionFilesViewModel); + + this.renderDisposables.add(this.sessionFilesWidget.setInput(sessionFilesViewModel)); + } + // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const changes = changesObs.read(reader); @@ -896,6 +903,51 @@ export class ChangesViewPane extends ViewPane { this.splitView.layout(availableHeight); } + /** + * Wires a collapsible section widget (CI checks / session files) to its + * SplitView pane: toggling its header collapses/restores the pane, and + * changes to its content show/hide the pane and re-layout. Both section + * widgets share the same structural contract so this logic is reused. + */ + private _wireSectionPane( + widget: { readonly collapsed: boolean; readonly visible: boolean; readonly onDidToggleCollapsed: Event; readonly onDidChangeHeight: Event }, + paneIndex: number, + headerHeight: number, + preferredHeight: number, + ): void { + let savedPaneHeight = preferredHeight; + + this._register(widget.onDidToggleCollapsed(collapsed => { + if (!this.splitView) { + return; + } + if (collapsed) { + // Save current size before collapsing + const currentSize = this.splitView.getViewSize(paneIndex); + if (currentSize > headerHeight) { + savedPaneHeight = currentSize; + } + this.splitView.resizeView(paneIndex, headerHeight); + } else { + // Restore saved size on expand + this.splitView.resizeView(paneIndex, savedPaneHeight); + } + this.layoutSplitView(); + })); + + this._register(widget.onDidChangeHeight(() => { + if (!this.splitView) { + return; + } + const visible = widget.visible; + const isCurrentlyVisible = this.splitView.isViewVisible(paneIndex); + if (visible !== isCurrentlyVisible) { + this.splitView.setViewVisible(paneIndex, visible); + } + this.layoutSplitView(); + })); + } + private getTreeSelection(): IChangesFileItem[] { const selection = this.tree?.getSelection() ?? []; return selection.filter(item => !!item && isChangesFileItem(item)); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 032811d7c3a..eafd1556e23 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -108,13 +108,24 @@ export class ChangesViewService extends Disposable implements IChangesViewServic const activeSessionChangesets = this.activeSessionChangesetsObs.read(reader) ?? []; // Honor an explicit selection only while it is still enabled; otherwise fall - // back to the default changeset so the picker never shows a disabled selection. + // back to the default, first enabled changeset so the picker never shows a + // disabled selection. const selectedChangeset = selectedChangesetId ? activeSessionChangesets .find(c => c.id === selectedChangesetId && c.isEnabled.read(reader)) : undefined; - return selectedChangeset ?? activeSessionChangesets.find(c => c.isDefault.read(reader)); + if (selectedChangeset) { + return selectedChangeset; + } + + const defaultChangeset = activeSessionChangesets + .find(c => c.isDefault.read(reader)); + + const firstEnabledChangeset = activeSessionChangesets + .find(c => c.isEnabled.read(reader)); + + return defaultChangeset ?? firstEnabledChangeset; }); this.activeSessionChangesetOperationsObs = derived(reader => { diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css b/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css new file mode 100644 index 00000000000..925515a4a26 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Session Files Widget - between the files list and the CI checks widget */ +.session-files-widget { + display: flex; + flex-direction: column; + flex-shrink: 0; + box-sizing: border-box; + font-size: var(--vscode-agents-fontSize-label1, 12px); +} + +/* Header */ +.session-files-widget-header { + position: relative; + display: flex; + align-items: center; + padding: 4px; + margin-top: 6px; + border-radius: var(--vscode-cornerRadius-medium); + min-height: 20px; + font-weight: var(--vscode-agents-fontWeight-semiBold, 600); + cursor: pointer; + user-select: none; +} + +.session-files-widget-header:hover { + padding-right: 32px; +} + +.session-files-widget-header:focus-visible { + padding-right: 32px; +} + +.session-files-widget-header.collapsed { + padding-right: 32px; +} + +/* Hover/keyboard focus promote the title to the strong foreground instead of + painting a row background — matches the sessions list section labels. */ +.session-files-widget-header:hover .session-files-widget-title, +.session-files-widget-header:focus-visible .session-files-widget-title { + color: var(--vscode-strongForeground); +} + +.session-files-widget-header:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Suppress the outline for mouse-driven focus — keyboard navigation only */ +.session-files-widget-header:focus:not(:focus-visible) { + outline: none !important; +} + +/* Chevron — right-aligned, visible on hover, keyboard focus, or when collapsed */ +.session-files-widget-header .group-chevron { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border-radius: var(--vscode-cornerRadius-small); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--vscode-codiconFontSize-compact, 12px); + visibility: hidden; + opacity: 0; +} + +.session-files-widget-header:hover .group-chevron, +.session-files-widget-header:focus-visible .group-chevron, +.session-files-widget-header.collapsed .group-chevron { + visibility: visible; + opacity: 0.7; +} + +.session-files-widget-header .group-chevron:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Title - single line, overflow ellipsis */ +.session-files-widget-title { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + color: var(--vscode-foreground); +} + +.session-files-widget-title-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* File count badge in the header */ +.session-files-widget-count { + flex-shrink: 0; + margin-left: auto; + padding-right: 8px; + font-size: var(--vscode-agents-fontSize-body2); + opacity: 0.7; +} + +.session-files-widget-header:hover .session-files-widget-count, +.session-files-widget-header:focus-visible .session-files-widget-count, +.session-files-widget-header.collapsed .session-files-widget-count { + visibility: hidden; +} + +/* Body - file list */ +.session-files-widget-body { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.session-files-widget-list { + height: 100%; + background-color: transparent; +} + +.session-files-widget-list > .monaco-list, +.session-files-widget-list > .monaco-list > .monaco-scrollable-element { + background-color: transparent; +} + +/* Individual file row */ +.session-files-widget .session-files-widget-list .monaco-list-row { + border-radius: var(--vscode-cornerRadius-small); +} + +.session-files-widget-file { + display: flex; + align-items: center; + gap: 6px; + padding: 0 0 0 6px; + height: 100%; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.session-files-widget-file .monaco-icon-label { + display: flex; + flex: 1; + min-width: 0; + width: 100%; +} + +.session-files-widget-file .monaco-icon-label .label-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts b/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts new file mode 100644 index 00000000000..cc25efd5808 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable } from '../../../../base/common/observable.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionFile } from '../../../services/sessions/common/session.js'; + +/** Shared stable empty result so "no session / no files" doesn't churn observers. */ +const EMPTY_SESSION_FILES: readonly ISessionFile[] = Object.freeze([]); + +/** + * View model backing the "Session Files" section in the changes view. Exposes + * the files created/edited/deleted outside the workspace by the active session. + */ +export class SessionFilesViewModel extends Disposable { + readonly sessionFilesObs: IObservable; + + constructor( + @ISessionsService sessionsService: ISessionsService, + ) { + super(); + + // The underlying `externalChanges` observable carries its own structural + // equality, so when it is unchanged it returns the same array reference + // and this derived does not propagate. The shared empty constant keeps + // the "no session" case equally stable. + this.sessionFilesObs = derived(this, reader => { + const activeSession = sessionsService.activeSession.read(reader); + return activeSession?.externalChanges?.read(reader) ?? EMPTY_SESSION_FILES; + }); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts b/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts new file mode 100644 index 00000000000..0280de781c5 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionFilesWidget.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { FileKind } from '../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ISessionFile, SessionFileOperation } from '../../../services/sessions/common/session.js'; + +const $ = dom.$; + +/** Minimal input contract for {@link SessionFilesWidget.setInput}. */ +export interface ISessionFilesInput { + readonly sessionFilesObs: IObservable; +} + +class SessionFileListDelegate implements IListVirtualDelegate { + static readonly ITEM_HEIGHT = 22; + + getHeight(_element: ISessionFile): number { + return SessionFileListDelegate.ITEM_HEIGHT; + } + + getTemplateId(_element: ISessionFile): string { + return SessionFileListRenderer.TEMPLATE_ID; + } +} + +interface ISessionFileTemplateData { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; +} + +class SessionFileListRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'sessionFile'; + readonly templateId = SessionFileListRenderer.TEMPLATE_ID; + + constructor( + private readonly _labels: ResourceLabels, + private readonly _labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): ISessionFileTemplateData { + const templateDisposables = new DisposableStore(); + const row = dom.append(container, $('.session-files-widget-file')); + const label = templateDisposables.add(this._labels.create(row, { supportIcons: true })); + return { label, templateDisposables }; + } + + renderElement(element: ISessionFile, _index: number, templateData: ISessionFileTemplateData): void { + const parent = dirname(element.uri); + templateData.label.setResource({ + resource: element.uri, + name: basename(element.uri), + description: this._labelService.getUriLabel(parent, { noPrefix: true }), + }, { + fileKind: FileKind.FILE, + strikethrough: element.operation === SessionFileOperation.Deleted, + title: getSessionFileTitle(element, this._labelService), + }); + } + + disposeTemplate(templateData: ISessionFileTemplateData): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * A widget that lists the files created, edited or deleted **outside** the + * session workspace during the session. Rendered between the changes tree and + * the CI checks widget in the changes view as a resizable SplitView pane. + * + * The collapse/resize behaviour mirrors {@link CIStatusWidget}. + */ +export class SessionFilesWidget extends Disposable { + + static readonly HEADER_HEIGHT = 32; // 5px section margin + 6px header margin + 28px header + static readonly MIN_BODY_HEIGHT = 3 * SessionFileListDelegate.ITEM_HEIGHT + 2; + static readonly PREFERRED_BODY_HEIGHT = 4 * SessionFileListDelegate.ITEM_HEIGHT; + static readonly MAX_BODY_HEIGHT = 240; + + private readonly _domNode: HTMLElement; + private readonly _headerNode: HTMLElement; + private readonly _titleNode: HTMLElement; + private readonly _titleLabelNode: HTMLElement; + private readonly _countNode: HTMLElement; + private readonly _chevronNode: HTMLElement; + private readonly _bodyNode: HTMLElement; + private readonly _list: WorkbenchList; + private readonly _labels: ResourceLabels; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private readonly _onDidToggleCollapsed = this._register(new Emitter()); + readonly onDidToggleCollapsed = this._onDidToggleCollapsed.event; + + private _fileCount = 0; + private _collapsed = false; + + get element(): HTMLElement { + return this._domNode; + } + + /** The full content height the widget would like (header + all files). */ + get desiredHeight(): number { + if (this._fileCount === 0) { + return 0; + } + if (this._collapsed) { + return SessionFilesWidget.HEADER_HEIGHT; + } + return SessionFilesWidget.HEADER_HEIGHT + this._fileCount * SessionFileListDelegate.ITEM_HEIGHT; + } + + /** Whether the widget is currently visible (has files to show). */ + get visible(): boolean { + return this._fileCount > 0; + } + + /** Whether the body is collapsed (header-only). */ + get collapsed(): boolean { + return this._collapsed; + } + + constructor( + container: HTMLElement, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILabelService private readonly _labelService: ILabelService, + @IEditorService private readonly _editorService: IEditorService, + @IHoverService private readonly _hoverService: IHoverService, + ) { + super(); + this._labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + this._domNode = dom.append(container, $('.session-files-widget')); + this._domNode.style.display = 'none'; + + // Header (always visible, click to collapse/expand) + this._headerNode = dom.append(this._domNode, $('.session-files-widget-header')); + this._titleNode = dom.append(this._headerNode, $('.session-files-widget-title')); + this._titleLabelNode = dom.append(this._titleNode, $('.session-files-widget-title-label')); + this._titleLabelNode.textContent = localize('sessionFiles.label', "Session Files"); + this._countNode = dom.append(this._titleNode, $('.session-files-widget-count')); + this._chevronNode = dom.append(this._headerNode, $('.group-chevron')); + this._chevronNode.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + + this._headerNode.setAttribute('role', 'button'); + this._headerNode.setAttribute('aria-label', localize('sessionFiles.toggle', "Toggle Session Files")); + this._headerNode.setAttribute('aria-expanded', 'true'); + this._headerNode.tabIndex = 0; + + this._register(this._hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + this._headerNode, + localize('sessionFiles.hover', "Files created or edited outside the workspace during this session. These files are not part of the workspace and won't be committed."), + )); + + // Register the gesture target so the toggle works on touch platforms + // (notably iOS) in the Sessions window, then handle both mouse click and + // touch tap. + this._register(Gesture.addTarget(this._headerNode)); + for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { + this._register(dom.addDisposableListener(this._headerNode, eventType, () => { + this._toggleCollapsed(); + })); + } + this._register(dom.addDisposableListener(this._headerNode, dom.EventType.KEY_DOWN, e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target === this._headerNode) { + e.preventDefault(); + this._toggleCollapsed(); + } + })); + + // Body (list of files) + const bodyId = 'session-files-widget-body'; + this._bodyNode = dom.append(this._domNode, $(`.${bodyId}`)); + this._bodyNode.id = bodyId; + this._headerNode.setAttribute('aria-controls', bodyId); + + const listContainer = $('.session-files-widget-list'); + this._list = this._register(this._instantiationService.createInstance( + WorkbenchList, + 'SessionFilesWidget', + listContainer, + new SessionFileListDelegate(), + [new SessionFileListRenderer(this._labels, this._labelService)], + { + multipleSelectionSupport: false, + openOnSingleClick: true, + accessibilityProvider: { + getWidgetAriaLabel: () => localize('sessionFiles.listAriaLabel', "Session Files"), + getAriaLabel: item => localize('sessionFiles.fileAriaLabel', "{0}, {1}", basename(item.uri), getSessionFileOperationLabel(item.operation)), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: item => basename(item.uri), + }, + }, + )); + this._bodyNode.appendChild(listContainer); + + this._register(this._list.onDidOpen(e => { + if (e.element) { + void this._openFile(e.element, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned); + } + })); + } + + setInput(input: ISessionFilesInput): IDisposable { + return autorun(reader => { + const files = input.sessionFilesObs.read(reader); + + const oldCount = this._fileCount; + this._fileCount = files.length; + + if (files.length === 0) { + this._setCollapsed(false); + this._renderBody([]); + this._domNode.style.display = 'none'; + if (oldCount !== 0) { + this._onDidChangeHeight.fire(); + } + return; + } + + this._domNode.style.display = ''; + this._renderHeader(files); + this._renderBody(files); + + if (this._fileCount !== oldCount) { + this._onDidChangeHeight.fire(); + } + }); + } + + private _renderHeader(files: readonly ISessionFile[]): void { + this._countNode.textContent = `${files.length}`; + } + + /** + * Layout the widget body list to the given height. + * Called by the parent view after computing available space. + */ + layout(height: number): void { + if (this._collapsed) { + this._bodyNode.style.display = 'none'; + return; + } + this._bodyNode.style.display = ''; + this._list.layout(height); + } + + private _toggleCollapsed(): void { + this._setCollapsed(!this._collapsed); + this._onDidToggleCollapsed.fire(this._collapsed); + 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 files list. Falls back to the header when the + * body is collapsed or there is nothing to focus. + */ + focus(): void { + if (this._collapsed || this._fileCount === 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(); + this._headerNode.classList.toggle('collapsed', collapsed); + this._headerNode.setAttribute('aria-expanded', String(!collapsed)); + } + + private _updateChevron(): void { + this._chevronNode.className = 'group-chevron'; + this._chevronNode.classList.add( + ...ThemeIcon.asClassNameArray( + this._collapsed ? Codicon.chevronRight : Codicon.chevronDown + ) + ); + } + + private _renderBody(files: readonly ISessionFile[]): void { + this._list.splice(0, this._list.length, files); + } + + private async _openFile(file: ISessionFile, preserveFocus: boolean, pinned: boolean): Promise { + // Created and deleted files open normally; modified files open a diff + // against their pre-session content when it is available. + if (file.operation === SessionFileOperation.Modified && file.originalUri) { + await this._editorService.openEditor({ + original: { resource: file.originalUri }, + modified: { resource: file.uri }, + label: getDiffEditorLabel(file.uri, this._labelService), + options: { preserveFocus, pinned }, + }, ACTIVE_GROUP); + return; + } + + await this._editorService.openEditor({ + resource: file.uri, + options: { preserveFocus, pinned }, + }, ACTIVE_GROUP); + } +} + +function getSessionFileOperationLabel(operation: SessionFileOperation): string { + switch (operation) { + case SessionFileOperation.Created: + return localize('sessionFiles.created', "Created"); + case SessionFileOperation.Modified: + return localize('sessionFiles.modified', "Modified"); + case SessionFileOperation.Deleted: + return localize('sessionFiles.deleted', "Deleted"); + } +} + +function getSessionFileTitle(file: ISessionFile, labelService: ILabelService): string { + const path = labelService.getUriLabel(file.uri); + return localize('sessionFiles.title', "{0} ({1})", path, getSessionFileOperationLabel(file.operation)); +} + +function getDiffEditorLabel(uri: URI, labelService: ILabelService): string { + return localize('sessionFiles.diffLabel', "{0} (Session Changes)", basename(uri) || labelService.getUriLabel(uri)); +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts new file mode 100644 index 00000000000..ed60eea7687 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; +import { FocusedViewContext } from '../../../../workbench/common/contextkeys.js'; +import { CHANGES_VIEW_ID } from '../common/changes.js'; +import { ChangesViewPane } from './changesView.js'; + +/** + * Accessibility help dialog for the Changes view. Documents the file tree and + * the two collapsible sections beneath it — Session Files and Checks — and how + * to operate them with the keyboard. + */ +export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 115; + readonly name = 'sessionsChanges'; + readonly type = AccessibleViewType.Help; + readonly when = FocusedViewContext.isEqualTo(CHANGES_VIEW_ID); + + getProvider(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + + const content: string[] = []; + content.push(localize('sessionsChanges.overview', "You are in the Changes view. It shows the files changed by the current session as a tree, followed by two collapsible sections: Session Files and Checks.")); + content.push(localize('sessionsChanges.tree', "Use the up and down arrow keys to move between changed files, and the left and right arrow keys to collapse or expand folders. Press Enter to open the selected file's diff.")); + content.push(localize('sessionsChanges.sessionFiles', "The Session Files section lists files that were created, edited, or deleted outside the workspace during this session, such as configuration files in your home directory. These files are not part of the workspace and won't be committed.")); + content.push(localize('sessionsChanges.sessionFilesToggle', "The Session Files header is a button. Press Enter or Space to collapse or expand the list. When expanded, use the arrow keys to move through the files and press Enter to open one: created or deleted files open in an editor, while edited files open as a diff against their pre-session content.")); + content.push(localize('sessionsChanges.checks', "The Checks section lists the continuous integration checks for the session's pull request. Its header is a button: press Enter or Space to collapse or expand it{0}.", '')); + content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); + + return new AccessibleContentProvider( + AccessibleViewProviderId.SessionsChanges, + { type: AccessibleViewType.Help }, + () => content.join('\n'), + () => { + const view = viewsService.getViewWithId(CHANGES_VIEW_ID); + view?.focus(); + }, + AccessibilityVerbositySettingId.SessionsChanges, + ); + } +} diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionFilesWidget.fixture.ts b/src/vs/sessions/contrib/changes/test/browser/sessionFilesWidget.fixture.ts new file mode 100644 index 00000000000..5802b1a2dcc --- /dev/null +++ b/src/vs/sessions/contrib/changes/test/browser/sessionFilesWidget.fixture.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 { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IDecorationsService } from '../../../../../workbench/services/decorations/common/decorations.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { INotebookDocumentService } from '../../../../../workbench/services/notebook/common/notebookDocumentService.js'; +import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ISessionFile, SessionFileOperation } from '../../../../services/sessions/common/session.js'; +import { ISessionFilesInput, SessionFilesWidget } from '../../browser/sessionFilesWidget.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; + +const SAMPLE_FILES: readonly ISessionFile[] = [ + { uri: URI.file('/home/user/.bashrc'), operation: SessionFileOperation.Modified, originalUri: URI.file('/home/user/.bashrc.orig') }, + { uri: URI.file('/home/user/.config/app/settings.json'), operation: SessionFileOperation.Created }, + { uri: URI.file('/home/user/.cache/tmp/scratch.log'), operation: SessionFileOperation.Deleted }, + { uri: URI.file('/tmp/agent-notes.md'), operation: SessionFileOperation.Created }, +]; + +function renderWidget(ctx: ComponentFixtureContext, options?: { files?: readonly ISessionFile[]; height?: number }): void { + ctx.container.style.width = '360px'; + ctx.container.style.height = `${options?.height ?? 160}px`; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + // Services required by ResourceLabels (file labels in the list). + reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); + reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }()); + reg.definePartialInstance(INotebookDocumentService, { getNotebook: () => undefined }); + // Required by WorkbenchList (the files list). + reg.define(IListService, ListService); + registerWorkbenchServices(reg); + reg.defineInstance(IEditorService, new class extends mock() { + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidVisibleEditorsChange = Event.None; + override readonly onDidEditorsChange = Event.None; + override async openEditor(): Promise { return undefined; } + }()); + }, + }); + + const files = options?.files ?? SAMPLE_FILES; + const input: ISessionFilesInput = { + sessionFilesObs: observableValue('fixtureSessionFiles', files), + }; + + const widget = ctx.disposableStore.add(instantiationService.createInstance(SessionFilesWidget, ctx.container)); + ctx.disposableStore.add(widget.setInput(input)); + + // The widget normally lives inside a SplitView pane that drives its layout; + // the fixture sizes it directly so the list renders. + const totalHeight = options?.height ?? 160; + widget.element.style.height = `${totalHeight}px`; + widget.layout(Math.max(0, totalHeight - SessionFilesWidget.HEADER_HEIGHT)); +} + +export default defineThemedFixtureGroup({ path: 'changes/' }, { + + WithFiles: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx), + }), + + SingleCreatedFile: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { + files: [{ uri: URI.file('/home/user/.gitconfig'), operation: SessionFileOperation.Created }], + height: 96, + }), + }), + + Empty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { files: [], height: 96 }), + }), + +}); diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 9093a9966e8..b779e6681fd 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -100,6 +100,7 @@ export function makeSession(resource: URI, opts?: { sticky: observableValue('sticky', false), openChats: observableValue('openChats', [chat]), closedChats: constObservable([]), + visibleChatTabs: constObservable([chat]), }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 266a9883127..5904b274e6e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -66,7 +66,7 @@ export function createChangesets( return sessionChangesets; } -function createActiveSessionSubscriptionObs( +export function createActiveSessionSubscriptionObs( options: IAgentHostAdapterOptions, isActiveSessionObs: IObservable, component: StateComponents, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts new file mode 100644 index 00000000000..b92e4ea473b --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts @@ -0,0 +1,394 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constObservable, derivedOpts, IObservable, mapObservableArrayCached } from '../../../../../base/common/observable.js'; +import { compare as strCompare } from '../../../../../base/common/strings.js'; +import { getComparisonKey, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; +import type { FileEdit } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { + buildDefaultChatUri, + type ChatState, + FileEditKind, + ResponsePartKind, + type SessionState, + StateComponents, + type Turn, + type ToolCallState, + ToolCallStatus, + ToolResultContentType, +} from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ISessionFile, ISessionWorkspace, SessionFileOperation } from '../../../../services/sessions/common/session.js'; +import { createActiveSessionSubscriptionObs } from './agentHostSessionChangesets.js'; +import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js'; + +/** + * A single file edit emitted by a tool call, decoded from the protocol so the + * reducer can classify it. Ordered so creations seen before edits keep the + * "created" classification. + */ +export interface IParsedFileEdit { + readonly kind: FileEditKind; + /** After-state URI (create/edit/rename target). */ + readonly afterUri?: URI; + /** Before-state URI (delete source / rename origin). */ + readonly beforeUri?: URI; + /** Before-content URI, used to render a diff for modified files. */ + readonly beforeContentUri?: URI; +} + +/** + * Builds the observable list of files that were created, edited or deleted + * **outside** the session's workspace folders during the session. + * + * The data is parsed from the agent-host chat-state turns: each turn's + * response parts are scanned for tool calls, and each tool call's file-edit + * results (and pending edits) are collected. The resulting edits are reduced + * per file so that a file first created and then edited is reported as + * {@link SessionFileOperation.Created}, while a deleted file is reported as + * {@link SessionFileOperation.Deleted}. + * + * Computation only happens for the active, non-archived session: archived + * sessions never open a live chat-state subscription, so no parsing work is + * done for them. + */ +export function createSessionFilesObs( + sessionUri: URI, + options: IAgentHostAdapterOptions, + isActiveSessionObs: IObservable, + isArchivedObs: IObservable, + workspaceObs: IObservable, +): IObservable { + const mapDiffUri = options.mapDiffUri; + + // Session files are only computed for the active, non-archived session. The + // subscriptions and parsing below are all gated on this so an archived + // session does no work. + const enabledObs = derivedOpts({ equalsFn: (a, b) => a === b }, reader => + isActiveSessionObs.read(reader) && !isArchivedObs.read(reader)); + + // Subscribe to the session to discover its chats. + const sessionStateObs = createActiveSessionSubscriptionObs( + options, + enabledObs, + StateComponents.Session, + constObservable(sessionUri), + ); + + // All chat URIs in the session (default chat + any peer chats). File edits + // can be produced by any chat, so we union edits across all of them. + const chatUrisObs = derivedOpts({ equalsFn: (a, b) => a.length === b.length && a.every((u, i) => isEqual(u, b[i])) }, reader => { + if (!enabledObs.read(reader)) { + return []; + } + const sessionState = sessionStateObs.read(reader).read(reader); + const defaultChatUri = URI.parse(buildDefaultChatUri(sessionUri)); + if (!sessionState || sessionState instanceof Error) { + return [defaultChatUri]; + } + + const uris = new Map(); + uris.set(defaultChatUri.toString(), defaultChatUri); + for (const chat of sessionState.chats) { + const uri = URI.parse(chat.resource); + uris.set(uri.toString(), uri); + } + return [...uris.values()]; + }); + + // One observable of parsed edits per chat, subscribing to that chat's state. + // + // Completed turns (`chatState.turns`) are immutable once finalized, so each + // is parsed exactly once and memoized by turn id in a closure-scoped cache + // that lives for the chat's lifetime. Only the in-progress `activeTurn` is + // re-parsed on every streamed delta, making delta updates O(active turn) + // rather than O(all turns). The `equalsFn` ensures the downstream reducer + // only re-runs when the parsed edits actually change (e.g. not for markdown + // or reasoning deltas that carry no file edits). + const editsPerChatObs = mapObservableArrayCached(undefined, chatUrisObs, (chatUri) => { + const chatStateObs = createActiveSessionSubscriptionObs( + options, + enabledObs, + StateComponents.Chat, + constObservable(chatUri), + ); + const parse = createIncrementalChatFileEditsParser(mapDiffUri); + return derivedOpts({ equalsFn: parsedFileEditsEqual }, reader => { + const chatState = chatStateObs.read(reader).read(reader); + if (!chatState || chatState instanceof Error) { + return []; + } + return parse(chatState); + }); + }, chatUri => chatUri.toString()); + + return derivedOpts({ equalsFn: sessionFilesEqual }, reader => { + const workspace = workspaceObs.read(reader); + const folderRoots = (workspace?.folders ?? []).map(f => f.workingDirectory); + + const allEdits: IParsedFileEdit[] = []; + for (const chatEditsObs of editsPerChatObs.read(reader)) { + allEdits.push(...chatEditsObs.read(reader)); + } + + return reduceSessionFiles(allEdits, folderRoots); + }); +} + +/** + * Minimal shape of a turn needed to parse its file edits. {@link Turn} is + * structurally assignable to this, so production passes a real `ChatState` + * while tests can build lightweight fixtures. + */ +export interface IFileEditTurn { + readonly id: string; + readonly responseParts: Turn['responseParts']; +} + +/** A chat state reduced to just the fields needed to parse its file edits. */ +export interface IFileEditChatState { + readonly turns?: readonly IFileEditTurn[]; + readonly activeTurn?: { readonly responseParts: Turn['responseParts'] }; +} + +/** Parses the file edits contained in a single turn's response parts. */ +export type ParseTurnFileEdits = (responseParts: Turn['responseParts']) => readonly IParsedFileEdit[]; + +/** + * Creates a stateful parser that turns a chat state into its ordered list of + * file edits, **parsing each completed turn at most once**. + * + * Completed turns (`chatState.turns`) are immutable once finalized, so each is + * parsed once and memoized by turn id in the returned closure. Only the + * in-progress `activeTurn` is re-parsed on every call, making streamed-delta + * updates O(active turn) rather than O(all turns). + * + * @param mapDiffUri Optional URI mapper applied while parsing. + * @param parseTurn Per-turn parse function. Defaults to {@link parseResponseParts}; + * injectable so tests can observe how often each turn is (re)parsed. + */ +export function createIncrementalChatFileEditsParser( + mapDiffUri?: (uri: URI) => URI, + parseTurn: ParseTurnFileEdits = responseParts => parseResponseParts(responseParts, mapDiffUri), +): (chatState: IFileEditChatState) => IParsedFileEdit[] { + const completedTurnCache = new Map(); + + return (chatState: IFileEditChatState): IParsedFileEdit[] => { + const edits: IParsedFileEdit[] = []; + const turns: readonly IFileEditTurn[] = chatState.turns ?? []; + + // Evict cache entries for turns that are no longer completed (e.g. a turn + // that moved back to `activeTurn`, or a discarded turn) so the cache can't + // grow unbounded or return stale data. + const completedIds = new Set(turns.map(t => t.id)); + for (const id of completedTurnCache.keys()) { + if (!completedIds.has(id)) { + completedTurnCache.delete(id); + } + } + + for (const turn of turns) { + let parsed = completedTurnCache.get(turn.id); + if (!parsed) { + parsed = parseTurn(turn.responseParts); + completedTurnCache.set(turn.id, parsed); + } + if (parsed.length > 0) { + edits.push(...parsed); + } + } + + if (chatState.activeTurn) { + edits.push(...parseTurn(chatState.activeTurn.responseParts)); + } + + return edits; + }; +} + +/** Parses the file edits contained in a turn's response parts (stateless). */ +export function parseResponseParts(responseParts: Turn['responseParts'], mapDiffUri?: (uri: URI) => URI): IParsedFileEdit[] { + const out: IParsedFileEdit[] = []; + for (const part of responseParts) { + if (part.kind !== ResponsePartKind.ToolCall) { + continue; + } + for (const fileEdit of getToolCallFileEdits(part.toolCall)) { + const parsed = parseFileEdit(fileEdit, mapDiffUri); + if (parsed) { + out.push(parsed); + } + } + } + return out; +} + +/** + * Extracts the {@link FileEdit | file edits} from a tool call regardless of its + * lifecycle state: completed/running results carry them in `content`, while a + * tool call awaiting confirmation carries the planned edits in `edits.items`. + */ +function getToolCallFileEdits(toolCall: ToolCallState): FileEdit[] { + const edits: FileEdit[] = []; + + // Completed/running results carry file edits in `content`... + if (toolCall.status === ToolCallStatus.Running + || toolCall.status === ToolCallStatus.Completed + || toolCall.status === ToolCallStatus.PendingResultConfirmation) { + for (const c of toolCall.content ?? []) { + if (c.type === ToolResultContentType.FileEdit) { + edits.push(c); + } + } + } else if (toolCall.status === ToolCallStatus.PendingConfirmation) { + // ...while a tool call awaiting confirmation carries the planned edits. + edits.push(...(toolCall.edits?.items ?? [])); + } + + return edits; +} + +function parseFileEdit(fileEdit: FileEdit, mapDiffUri?: (uri: URI) => URI): IParsedFileEdit | undefined { + const normalized = normalizeFileEdit(fileEdit); + if (!normalized) { + return undefined; + } + const map = (uri: URI | undefined): URI | undefined => uri ? (mapDiffUri ? mapDiffUri(uri) : uri) : undefined; + return { + kind: normalized.kind, + afterUri: map(normalized.afterUri), + beforeUri: map(normalized.beforeUri), + beforeContentUri: map(normalized.beforeContentUri), + }; +} + +interface IMutableSessionFile { + operation: SessionFileOperation; + originalUri?: URI; +} + +/** + * Reduces an ordered list of parsed file edits into the final per-file state. + * + * Rules: + * - A file created during the session stays {@link SessionFileOperation.Created} + * even if edited afterwards. + * - A deleted file becomes {@link SessionFileOperation.Deleted} (overriding any + * earlier create/edit). + * - Renames are modeled as a delete of the source plus a create of the target. + * - Only files outside every workspace folder root are kept. + */ +export function reduceSessionFiles(edits: readonly IParsedFileEdit[], folderRoots: readonly URI[]): ISessionFile[] { + const byUri = new Map(); + + const isOutsideWorkspace = (uri: URI): boolean => + !folderRoots.some(root => isEqualOrParent(uri, root)); + + const setCreated = (uri: URI): void => { + if (!isOutsideWorkspace(uri)) { + return; + } + byUri.set(getComparisonKey(uri), { uri, file: { operation: SessionFileOperation.Created } }); + }; + + const setModified = (uri: URI, originalUri: URI | undefined): void => { + if (!isOutsideWorkspace(uri)) { + return; + } + const existing = byUri.get(getComparisonKey(uri)); + if (existing?.file.operation === SessionFileOperation.Created) { + return; // created-then-edited stays created + } + if (existing?.file.operation === SessionFileOperation.Modified) { + // Keep the earliest known original content for the diff. + existing.file.originalUri = existing.file.originalUri ?? originalUri; + return; + } + byUri.set(getComparisonKey(uri), { uri, file: { operation: SessionFileOperation.Modified, originalUri } }); + }; + + const setDeleted = (uri: URI, originalUri: URI | undefined): void => { + if (!isOutsideWorkspace(uri)) { + return; + } + const existing = byUri.get(getComparisonKey(uri)); + byUri.set(getComparisonKey(uri), { uri, file: { operation: SessionFileOperation.Deleted, originalUri: existing?.file.originalUri ?? originalUri } }); + }; + + for (const edit of edits) { + switch (edit.kind) { + case FileEditKind.Create: + if (edit.afterUri) { + setCreated(edit.afterUri); + } + break; + case FileEditKind.Edit: + if (edit.afterUri) { + setModified(edit.afterUri, edit.beforeContentUri); + } + break; + case FileEditKind.Delete: + if (edit.beforeUri) { + setDeleted(edit.beforeUri, edit.beforeContentUri); + } + break; + case FileEditKind.Rename: + if (edit.beforeUri) { + setDeleted(edit.beforeUri, edit.beforeContentUri); + } + if (edit.afterUri) { + setCreated(edit.afterUri); + } + break; + } + } + + const files = [...byUri.values()].map(({ uri, file }): ISessionFile => ({ + uri, + operation: file.operation, + originalUri: file.originalUri, + })); + + files.sort((a, b) => strCompare(getComparisonKey(a.uri), getComparisonKey(b.uri))); + return files; +} + +function sessionFilesEqual(a: readonly ISessionFile[], b: readonly ISessionFile[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].operation !== b[i].operation + || !isEqual(a[i].uri, b[i].uri) + || !isEqual(a[i].originalUri, b[i].originalUri)) { + return false; + } + } + return true; +} + +/** + * Structural equality over parsed edits, used as the per-chat observable's + * `equalsFn` so streamed deltas that carry no file-edit change (e.g. markdown + * or reasoning content) don't re-run the downstream reducer. + */ +function parsedFileEditsEqual(a: readonly IParsedFileEdit[], b: readonly IParsedFileEdit[]): boolean { + if (a === b) { + return true; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].kind !== b[i].kind + || !isEqual(a[i].afterUri, b[i].afterUri) + || !isEqual(a[i].beforeUri, b[i].beforeUri) + || !isEqual(a[i].beforeContentUri, b[i].beforeContentUri)) { + return false; + } + } + return true; +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index e0d7c9b6bbb..8a86e24b510 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { disposableTimeout, raceTimeout } from '../../../../../base/common/async.js'; +import { disposableTimeout, DeferredPromise, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { arrayEquals, structuralEquals } from '../../../../../base/common/equals.js'; @@ -27,13 +27,14 @@ import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/ import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, type ChangesetFile, type ClientPluginCustomization, 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 { ActionType, isChatAction, isSessionAction, NotificationType, type ProgressParams } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, ROOT_STATE_URI, SessionMeta, 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'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IProgressService, IProgressStep, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; @@ -45,14 +46,16 @@ import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatOriginKind, IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; +import { ChatOriginKind, IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computeLivePullRequestIcon } from '../../../github/browser/pullRequestIconStatus.js'; +import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; import { changesetFileToChange, mapProtocolStatus } from './agentHostDiffs.js'; import { createChangesets } from './agentHostSessionChangesets.js'; +import { createSessionFilesObs } from './agentHostSessionFiles.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -265,6 +268,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly status: ISettableObservable; readonly changes: IObservable; readonly changesets: ISettableObservable; + readonly externalChanges: IObservable; readonly modelId: ISettableObservable; modelSelection: ModelSelection | undefined; readonly mode: ISettableObservable<{ readonly id: string; readonly kind: string } | undefined>; @@ -454,16 +458,17 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const livePR = prModelRef.object.pullRequest.read(reader); if (!livePR) { - // The live model hasn't been fetched yet (e.g. right after startup). Show - // the last known icon from the persistent cache so the row isn't icon-less - // while the first fetch is in flight. - const cachedIcon = this._pullRequestIconCache.get(prLink); - if (!cachedIcon) { - return baseGitHubInfo; - } + // The live model hasn't been fetched yet (e.g. right after a PR is first + // detected, or right after startup). Show the last known icon from the + // persistent cache, falling back to a neutral open-PR icon, so the row + // surfaces a PR icon immediately instead of the read/unread dot while the + // first fetch is in flight. The agent-host git state carries no PR state, + // so the live model refines it (merged/closed/draft/failing checks) within + // a poll cycle. + const icon = this._pullRequestIconCache.get(prLink) ?? computePullRequestIcon(GitHubPullRequestState.Open); return { ...baseGitHubInfo, - pullRequest: { ...baseGitHubInfo.pullRequest, icon: cachedIcon } + pullRequest: { ...baseGitHubInfo.pullRequest, icon } }; } @@ -516,6 +521,11 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this.changesSummary = changesSummary; this.changes = changes; + // Files created/edited/deleted outside the workspace, parsed from the + // chat-state turns. Computed lazily from the same active-session + // subscriptions used for changes. + this.externalChanges = createSessionFilesObs(sessionUri, this._options, this.isActiveSessionObs, this.isArchived, this.workspace); + // Changesets will be resolved asynchronously when the session is active. this.changesets = observableValue(this, []); @@ -1385,6 +1395,12 @@ class NewSession extends Disposable { session: backendUri, workingDirectory: this.workspaceUri, config: this._config?.values, + // MCP-style opt-in: offer to receive `progress` for any + // long-running bring-up (chiefly the lazy first-use SDK + // download, which fires later at first-message + // materialization). The host echoes this token on each + // `progress` frame so `_handleProgress` can correlate it. + progressToken: generateUuid(), ...(this._selectedAgent ? { agent: { uri: this._selectedAgent.uri } } : {}), ...(this._initialActiveClient ? { activeClient: this._initialActiveClient } : {}), }); @@ -1502,6 +1518,19 @@ class NewSession extends Disposable { * URI-scheme mapping for session metadata, the agent-provider lookup, and * the browse UI. */ +/** + * One in-flight download, tracked by + * {@link BaseAgentHostSessionsProvider._activeDownloads}. Owns the lifecycle + * of a single notification progress: `report` pushes a step, `complete` + * resolves the backing deferred so the notification is dismissed. + */ +interface IActiveDownload { + /** Last reported determinate percentage, used to compute progress increments. */ + lastPercent: number; + report(step: IProgressStep): void; + complete(): void; +} + export abstract class BaseAgentHostSessionsProvider extends Disposable implements IAgentHostSessionsProvider { abstract readonly id: string; @@ -1553,6 +1582,18 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map(); + /** + * Active progress indicators keyed by `progressToken`. Today's only + * producer is the agent host's lazy, first-use SDK download, which is + * provider-global: the host emits a single stream per download keyed by the + * download's own stable identity (so distinct sessions of a provider share + * one indicator). Each entry owns one long-running notification progress + * (opened on the first frame), driven via {@link IActiveDownload.report} and + * dismissed via {@link IActiveDownload.complete} once `progress >= total`. + * See {@link _handleProgress}. + */ + private readonly _activeDownloads = new Map(); + /** * Temporary session that has been sent (first turn dispatched) but not yet * committed by the backend session list. Shown in the session list until the @@ -1656,6 +1697,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement @IStorageService protected readonly _storageService: IStorageService, @IDialogService protected readonly _dialogService: IDialogService, @IWorkspaceTrustManagementService protected readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IProgressService protected readonly _progressService: IProgressService, ) { super(); this._register(toDisposable(() => { @@ -1664,6 +1706,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } this._sessionCache.clear(); })); + this._register(toDisposable(() => { + for (const download of this._activeDownloads.values()) { + download.complete(); + } + this._activeDownloads.clear(); + })); } // -- Subclass hooks ------------------------------------------------------- @@ -3478,6 +3526,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._handleSessionRemoved(n.session); } else if (n.type === NotificationType.SessionSummaryChanged) { this._handleSessionSummaryChanged(n.session, n.changes); + } else if (n.type === NotificationType.Progress) { + this._handleProgress(n); } })); @@ -3614,6 +3664,79 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement }); } + /** + * Render a generic `progress` notification as a notification progress bar. + * Progress is correlated by {@link ProgressParams.progressToken}; today's + * only producer is the agent host's lazy, first-use SDK download, which the + * host surfaces as a single stream per provider keyed by the download's own + * stable identity — so one indicator per download regardless of how many + * sessions await it. Determinate when the host knows the `total` + * (`Content-Length`), or a byte-count spinner otherwise. The operation is + * complete — and the notification dismissed — once `progress >= total`. The + * human-readable brand noun rides on {@link ProgressParams.message}. + */ + private _handleProgress(progress: ProgressParams): void { + // New AI UI must stay hidden when the user has turned AI features off. + if (this._baseConfigurationService.getValue(ChatConfiguration.AIDisabled)) { + return; + } + + // Complete when we reach the (possibly server-synthesized) total. The + // host emits a terminal frame with `progress === total` for success, + // indeterminate completion, and failure alike; real errors surface via + // the session-failure path, not here. + const isComplete = progress.total !== undefined && progress.progress >= progress.total; + if (isComplete) { + this._activeDownloads.get(progress.progressToken)?.complete(); + this._activeDownloads.delete(progress.progressToken); + return; + } + + let entry = this._activeDownloads.get(progress.progressToken); + if (!entry) { + // First frame for this download: open one long-running notification + // progress and drive it via `report` until a terminal frame resolves + // `deferred`. `message` is the host-supplied, already-localized title + // (e.g. "Downloading Claude agent…"); render it verbatim so this stays + // a generic indicator that makes no assumption about what's downloading. + const deferred = new DeferredPromise(); + let report: ((step: IProgressStep) => void) | undefined; + const title = progress.message ?? localize('agentHost.download.titleFallback', "Downloading…"); + this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title, + }, + p => { + report = step => p.report(step); + return deferred.p; + }, + ); + entry = { + lastPercent: 0, + report: step => report?.(step), + complete: () => deferred.complete(), + }; + this._activeDownloads.set(progress.progressToken, entry); + } + + if (progress.total && progress.total > 0) { + const percent = Math.max(0, Math.min(100, Math.round((progress.progress / progress.total) * 100))); + const increment = percent - entry.lastPercent; + entry.lastPercent = percent; + entry.report({ + message: localize('agentHost.download.percent', "{0}%", percent), + increment: increment > 0 ? increment : 0, + total: 100, + }); + } else { + // No total: indeterminate. Show megabytes received so the user + // still sees the download making progress. + const megabytes = (progress.progress / (1024 * 1024)).toFixed(1); + entry.report({ message: localize('agentHost.download.megabytes', "{0} MB", megabytes) }); + } + } + private _handleConfigChanged(session: string, config: Record, replace: boolean): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 8e4ecd200a8..273fff0f1d1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -18,6 +18,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../platform/progress/common/progress.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; @@ -87,8 +88,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IDialogService dialogService: IDialogService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IProgressService progressService: IProgressService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService, progressService); this._isSessionsWindow = environmentService.isSessionsWindow; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts new file mode 100644 index 00000000000..0e963ff480a --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + FileEditKind, + ResponsePartKind, + ToolCallConfirmationReason, + ToolCallStatus, + ToolResultContentType, + type ResponsePart, +} from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionFileOperation } from '../../../../../services/sessions/common/session.js'; +import { + createIncrementalChatFileEditsParser, + IFileEditChatState, + IParsedFileEdit, + parseResponseParts, + reduceSessionFiles, +} from '../../browser/agentHostSessionFiles.js'; + +// ── Protocol fixture helpers ──────────────────────────────────────────────── + +let seq = 0; + +function toolCallPart(toolCall: object): ResponsePart { + return { kind: ResponsePartKind.ToolCall, toolCall } as ResponsePart; +} + +function markdownPart(content: string): ResponsePart { + return { kind: ResponsePartKind.Markdown, id: `md-${seq++}`, content } as ResponsePart; +} + +/** A completed tool call carrying the given file-edit results. */ +function completedToolCallPart(content: object[]): ResponsePart { + return toolCallPart({ + status: ToolCallStatus.Completed, + toolCallId: `tc-${seq++}`, + toolName: 'editFile', + displayName: 'Edit File', + invocationMessage: 'Editing', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: 'Edited', + content, + }); +} + +/** A tool call awaiting confirmation, carrying its planned edits. */ +function pendingConfirmationToolCallPart(items: object[]): ResponsePart { + return toolCallPart({ + status: ToolCallStatus.PendingConfirmation, + toolCallId: `tc-${seq++}`, + toolName: 'editFile', + displayName: 'Edit File', + invocationMessage: 'Editing', + edits: { items }, + }); +} + +function createEdit(uri: string): object { + return { type: ToolResultContentType.FileEdit, after: { uri, content: { uri: `${uri}.after` } } }; +} + +function editEdit(uri: string): object { + return { + type: ToolResultContentType.FileEdit, + before: { uri, content: { uri: `${uri}.before` } }, + after: { uri, content: { uri: `${uri}.after` } }, + }; +} + +function deleteEdit(uri: string): object { + return { type: ToolResultContentType.FileEdit, before: { uri, content: { uri: `${uri}.before` } } }; +} + +function parsedEdit(kind: FileEditKind, uris: { after?: string; before?: string; beforeContent?: string }): IParsedFileEdit { + return { + kind, + afterUri: uris.after ? URI.file(uris.after) : undefined, + beforeUri: uris.before ? URI.file(uris.before) : undefined, + beforeContentUri: uris.beforeContent ? URI.file(uris.beforeContent) : undefined, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +suite('agentHostSessionFiles', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('incremental parser parses each completed turn once and re-parses only the active turn', () => { + // Count how many times each distinct responseParts array is parsed. + const parseCounts = new Map(); + const countingParseTurn = (parts: ResponsePart[]): readonly IParsedFileEdit[] => { + parseCounts.set(parts, (parseCounts.get(parts) ?? 0) + 1); + return []; + }; + + const parse = createIncrementalChatFileEditsParser(undefined, countingParseTurn); + + // Each turn / active-turn snapshot gets a uniquely-identifiable array. + const t1Parts: ResponsePart[] = []; + const t2Parts: ResponsePart[] = []; + const active1Parts: ResponsePart[] = []; + const active2Parts: ResponsePart[] = []; + const active3Parts: ResponsePart[] = []; + + // 1) First completed turn arrives. + parse({ turns: [{ id: 't1', responseParts: t1Parts }] }); + // 2) A turn starts streaming (active). + parse({ turns: [{ id: 't1', responseParts: t1Parts }], activeTurn: { responseParts: active1Parts } }); + // 3) Same active turn streams another delta. + parse({ turns: [{ id: 't1', responseParts: t1Parts }], activeTurn: { responseParts: active2Parts } }); + // 4) Active turn finalizes into t2. + parse({ turns: [{ id: 't1', responseParts: t1Parts }, { id: 't2', responseParts: t2Parts }] }); + // 5) A new turn starts streaming. + parse({ + turns: [{ id: 't1', responseParts: t1Parts }, { id: 't2', responseParts: t2Parts }], + activeTurn: { responseParts: active3Parts }, + }); + + // Completed turns are parsed exactly once regardless of how many deltas + // followed; each active-turn snapshot is parsed exactly once. + assert.deepStrictEqual( + { + t1: parseCounts.get(t1Parts), + t2: parseCounts.get(t2Parts), + active1: parseCounts.get(active1Parts), + active2: parseCounts.get(active2Parts), + active3: parseCounts.get(active3Parts), + }, + { t1: 1, t2: 1, active1: 1, active2: 1, active3: 1 }, + ); + }); + + test('incremental parser keeps completed-turn edits while a new turn streams', () => { + const parse = createIncrementalChatFileEditsParser(); + + const t1Parts = [completedToolCallPart([createEdit('file:///a.txt')])]; + const completed: IFileEditChatState = { turns: [{ id: 't1', responseParts: t1Parts }] }; + + const first = parse(completed); + const streaming = parse({ + turns: [{ id: 't1', responseParts: t1Parts }], + activeTurn: { responseParts: [completedToolCallPart([createEdit('file:///b.txt')])] }, + }); + + assert.deepStrictEqual( + { + first: first.map(e => e.afterUri?.toString()), + streaming: streaming.map(e => e.afterUri?.toString()), + }, + { + first: ['file:///a.txt'], + streaming: ['file:///a.txt', 'file:///b.txt'], + }, + ); + }); + + test('parseResponseParts extracts edits from completed and pending tool calls and ignores non-tool parts', () => { + const parts: ResponsePart[] = [ + markdownPart('hello'), + completedToolCallPart([createEdit('file:///created.txt'), editEdit('file:///edited.txt')]), + pendingConfirmationToolCallPart([deleteEdit('file:///deleted.txt')]), + ]; + + const parsed = parseResponseParts(parts); + + assert.deepStrictEqual( + parsed.map(e => ({ kind: e.kind, uri: (e.afterUri ?? e.beforeUri)?.toString() })), + [ + { kind: FileEditKind.Create, uri: 'file:///created.txt' }, + { kind: FileEditKind.Edit, uri: 'file:///edited.txt' }, + { kind: FileEditKind.Delete, uri: 'file:///deleted.txt' }, + ], + ); + }); + + test('reduceSessionFiles classifies operations and filters workspace files', () => { + const edits: IParsedFileEdit[] = [ + // created-then-edited outside workspace → Created + parsedEdit(FileEditKind.Create, { after: '/home/user/.config/app.json' }), + parsedEdit(FileEditKind.Edit, { after: '/home/user/.config/app.json', beforeContent: '/home/user/.config/app.json.before' }), + // edited outside workspace → Modified (keeps original for diff) + parsedEdit(FileEditKind.Edit, { after: '/home/user/.bashrc', beforeContent: '/home/user/.bashrc.before' }), + // deleted outside workspace → Deleted + parsedEdit(FileEditKind.Delete, { before: '/tmp/scratch.log', beforeContent: '/tmp/scratch.log.before' }), + // inside workspace → excluded + parsedEdit(FileEditKind.Create, { after: '/repo/src/index.ts' }), + ]; + + const files = reduceSessionFiles(edits, [URI.file('/repo')]); + + assert.deepStrictEqual( + files.map(f => ({ uri: f.uri.path, operation: f.operation, original: f.originalUri?.path })), + [ + { uri: '/home/user/.bashrc', operation: SessionFileOperation.Modified, original: '/home/user/.bashrc.before' }, + { uri: '/home/user/.config/app.json', operation: SessionFileOperation.Created, original: undefined }, + { uri: '/tmp/scratch.log', operation: SessionFileOperation.Deleted, original: '/tmp/scratch.log.before' }, + ], + ); + }); + + test('reduceSessionFiles models a rename as a delete of the source and a create of the target', () => { + const edits: IParsedFileEdit[] = [ + parsedEdit(FileEditKind.Rename, { before: '/home/user/old.txt', after: '/home/user/new.txt', beforeContent: '/home/user/old.txt.before' }), + ]; + + const files = reduceSessionFiles(edits, [URI.file('/repo')]); + + assert.deepStrictEqual( + files.map(f => ({ uri: f.uri.path, operation: f.operation })), + [ + { uri: '/home/user/new.txt', operation: SessionFileOperation.Created }, + { uri: '/home/user/old.txt', operation: SessionFileOperation.Deleted }, + ], + ); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts index ce455187c6e..4305a0566a5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts @@ -69,6 +69,7 @@ function makeActiveSession(providerId: string): IActiveSession { sticky: observableValue('sticky', false), openChats: observableValue('openChats', [chat]), closedChats: constObservable([]), + visibleChatTabs: constObservable([chat]), } satisfies IActiveSession; } 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 4c29e5408bd..426b1a15dc6 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 @@ -25,6 +25,7 @@ import { IDialogService, IFileDialogService } from '../../../../../../platform/d import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatModelReference, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -44,6 +45,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IGitHubService } from '../../../../github/browser/githubService.js'; import { GitHubPullRequestModel } from '../../../../github/browser/models/githubPullRequestModel.js'; import { IPullRequestIconCache, PullRequestIconCache } from '../../../../github/browser/pullRequestIconCache.js'; +import { computePullRequestIcon, GitHubPullRequestState } from '../../../../github/common/types.js'; import { IWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/common/environmentService.js'; // ---- Mock IAgentHostService ------------------------------------------------- @@ -344,6 +346,7 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen }); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IStorageService, options?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IProgressService, {}); instantiationService.stub(IGitHubService, options?.gitHubService ?? new class extends mock() { override findPullRequestNumberByHeadBranch = async () => undefined; }()); @@ -2790,6 +2793,44 @@ suite('LocalAgentHostSessionsProvider', () => { sub2.dispose(); })); + test('surfaces a default open-PR icon immediately when a PR is detected before the live model loads', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // A GitHub service whose live PR model is never populated (`pullRequest` stays + // undefined), mirroring the window right after a PR is first detected but before + // the first live fetch completes. Without a fallback the session list row would + // keep the read/unread dot instead of a PR icon until that fetch lands. + const gitHubService = new class extends mock() { + private readonly _model = { pullRequest: constObservable(undefined) } as unknown as GitHubPullRequestModel; + override createPullRequestModelReference = () => new ImmortalReference(this._model); + }(); + + agentHost.addSession(createSession('pr-default-icon', { summary: 'PR Session', project: { uri: URI.parse('file:///repo'), displayName: 'repo' } })); + const provider = createProvider(disposables, agentHost, undefined, { gitHubService }); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'PR Session'); + assert.ok(session); + + // Force a session-state subscription and push GitHub state carrying a PR URL so + // the session detects the pull request while its live model is still empty. + provider.getSessionConfig(session!.sessionId); + agentHost.setSessionState('pr-default-icon', 'copilotcli', { + provider: 'copilotcli', title: 'PR Session', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + _meta: { github: { owner: 'owner', repo: 'repo', pullRequestUrl: 'https://github.com/owner/repo/pull/42' } }, + }); + + const gitHubInfoObs = session!.workspace.get()!.folders[0]!.gitRepository!.gitHubInfo; + const sub = autorun(reader => { gitHubInfoObs.read(reader); }); + await timeout(0); + + const pullRequest = gitHubInfoObs.get()?.pullRequest; + assert.strictEqual(pullRequest?.number, 42, 'PR is detected from the GitHub state URL'); + assert.deepStrictEqual(pullRequest?.icon, computePullRequestIcon(GitHubPullRequestState.Open), 'a default open-PR icon is shown immediately while the live model is empty'); + sub.dispose(); + })); + // ---- replaceSessionConfig ------- test('replaceSessionConfig only replaces sessionMutable, non-readOnly values and preserves everything else', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 52e7591ca5f..9cf5a7d7acf 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -27,6 +27,7 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../platform/progress/common/progress.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -216,8 +217,9 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @IAgentHostActiveClientService activeClientService: IAgentHostActiveClientService, @IDialogService dialogService: IDialogService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IProgressService progressService: IProgressService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService, progressService); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 612b7545505..2f11502bfc5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -23,6 +23,7 @@ import { IDialogService, IFileDialogService } from '../../../../../../platform/d import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -216,6 +217,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne lookupLanguageModel: () => undefined, }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IProgressService, {}); instantiationService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path, }); diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts index 8fbc94e00e6..19fb1d9f0ce 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -40,8 +40,6 @@ interface ICIBannerState { readonly completed: number; /** Number of checks still running or queued. */ readonly pending: number; - /** Whether the user already requested a CI fix for the current PR head commit. */ - readonly fixRequested: boolean; } interface ICommentsBannerState { @@ -104,6 +102,11 @@ export class SessionInputBanners extends Disposable { if (!ciModel) { return undefined; } + // Once the user has requested a CI fix for the current PR head commit, + // hide the entire banner until a new commit lands on the PR. + if (ciModel.fixRequested.read(reader)) { + return undefined; + } const checks = ciModel.checks.read(reader); const failed = getFailedChecks(checks).length; if (failed === 0) { @@ -111,7 +114,7 @@ export class SessionInputBanners extends Disposable { } const completed = checks.filter(check => check.status === GitHubCheckStatus.Completed).length; const pending = checks.length - completed; - return { sessionId: session.sessionId, failed, completed, pending, fixRequested: ciModel.fixRequested.read(reader) }; + return { sessionId: session.sessionId, failed, completed, pending }; }); private readonly _commentsState: IObservable = derived(this, reader => { @@ -188,11 +191,11 @@ export class SessionInputBanners extends Disposable { ariaLabel: text, dismissTooltip: localize('ci.dismiss', "Hide for this session"), actions: [ - ...(state.fixRequested ? [] : [{ + { 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), diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index ad50d0da58c..8ca69c6faaf 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -16,20 +16,21 @@ import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from ' import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { getQuickNavigateHandler } from '../../../../workbench/browser/quickaccess.js'; +import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionChatsPickerVisibleContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; -import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ChatOriginKind, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; @@ -425,6 +426,338 @@ registerAction2(class AddChatToSessionAction extends Action2 { } }); +// -- Chat tab navigation & close (within the active session's tab strip) -- + +// These chords sit just above the session-level navigation/close commands so +// they win while a multi-chat session is focused, falling back to the +// session-level commands when the tab strip is not shown. +const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; + +function navigateChatTab(accessor: ServicesAccessor, direction: 'next' | 'previous'): void { + const sessionsService = accessor.get(ISessionsService); + const sessionsPartService = accessor.get(ISessionsPartService); + const extUri = accessor.get(IUriIdentityService).extUri; + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const tabs = session.visibleChatTabs.get(); + if (tabs.length < 2) { + return; + } + const activeChat = session.activeChat.get(); + const currentIndex = activeChat ? tabs.findIndex(chat => extUri.isEqual(chat.resource, activeChat.resource)) : -1; + const from = currentIndex === -1 ? 0 : currentIndex; + const delta = direction === 'next' ? 1 : -1; + const target = tabs[(from + delta + tabs.length) % tabs.length]; + sessionsService.openChat(session, target.resource); + sessionsPartService.focusSession(session); +} + +registerAction2(class NavigateNextChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.navigateNextChat', + title: localize2('navigateNextChat', "Go to Next Chat"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketRight, + }, + }); + } + override run(accessor: ServicesAccessor): void { + navigateChatTab(accessor, 'next'); + } +}); + +registerAction2(class NavigatePreviousChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.navigatePreviousChat', + title: localize2('navigatePreviousChat', "Go to Previous Chat"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketLeft, + }, + }); + } + override run(accessor: ServicesAccessor): void { + navigateChatTab(accessor, 'previous'); + } +}); + +// The close-chat action is both a keybinding (Ctrl/Cmd+W closes the active chat) +// and a per-tab toolbar action contributed to {@link Menus.SessionChatTab}: the +// chat tab strip renders this menu and forwards the tab's {@link IChatTabContext} +// as the action argument so the button closes that specific tab. +export interface IChatTabContext { + readonly session: IActiveSession; + readonly chat: IChat; +} + +registerAction2(class CloseChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.closeChat', + title: localize2('closeActiveChat', "Close Chat"), + icon: Codicon.close, + // Hidden from the palette: closing a specific chat is contextual (the + // keybinding targets the active chat; the menu targets a tab). + f1: false, + category: SessionsCategories.Sessions, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + // Intercept Ctrl/Cmd+W (which otherwise closes the session) only + // while the active chat is a closeable non-main chat, so it closes + // the chat tab instead — like closing a tab vs the window. + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionActiveChatIsClosableContext), + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, + }, + // Rendered as the tab's close button by the chat tab strip; the main + // chat's tab does not render this menu, so no per-tab gating is needed. + menu: { + id: Menus.SessionChatTab, + group: 'navigation', + order: 10, + }, + }); + } + override async run(accessor: ServicesAccessor, context?: IChatTabContext): Promise { + const sessionsService = accessor.get(ISessionsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const extUri = accessor.get(IUriIdentityService).extUri; + // From the tab menu: act on the forwarded tab's chat. From the keybinding: + // act on the active chat of the active session. + const session = context?.session ?? sessionsService.activeSession.get(); + if (!session) { + return; + } + const chat = context?.chat ?? session.activeChat.get(); + if (!chat || extUri.isEqual(chat.resource, session.mainChat.get().resource)) { + return; + } + // An untitled (in-composer) draft has nothing to reopen, so delete it + // outright; a committed chat is hidden (reopenable). + if (chat.status.get() === SessionStatus.Untitled) { + await sessionsManagementService.deleteChat(session, chat.resource, { skipConfirmation: true }); + } else { + await sessionsService.closeChat(session, chat); + } + } +}); + +// -- Show Chats Picker (chats within the active session) -- + +// A no-input quick pick (pure switcher) over the active session's open chats, +// each shown with a chat icon. Driven by Ctrl+Tab / Ctrl+Shift+Tab in +// editor-switcher (MRU) style: opens with quick navigate active, so holding the +// modifier and pressing Tab cycles and releasing accepts the focused chat. These +// are gated to sessions with more than one open chat at a higher weight than the +// session-history secondary on the same chord, so they fall back to session +// navigation otherwise. The same picker is also reachable from the palette ("Go +// to Chat in Session"), which additionally lists closed chats and skips drafts. + +export const SHOW_CHATS_PICKER_COMMAND_ID = 'sessions.showChatsPicker'; +const QUICK_SWITCH_NEXT_CHAT_ID = 'sessions.quickSwitchNextChat'; +const QUICK_SWITCH_PREVIOUS_CHAT_ID = 'sessions.quickSwitchPreviousChat'; +const CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID = 'sessions.chatsPicker.quickNavigateNext'; +const CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID = 'sessions.chatsPicker.quickNavigatePrevious'; + +// The open chords are gated to not fire while another quick pick is already +// showing (inQuickPickContext negated), so e.g. the editor's own Ctrl+Tab picker +// keeps the chord for its own navigation instead of this opening on top of it. +// The Ctrl+Tab MRU switcher cycles open chats only, so it is gated on more than +// one open tab. (The palette command, which also lists closed chats, is gated on +// more than one committed chat instead.) +const ChatsPickerScopeContext = ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext, inQuickPickContext.negate()); + +function openChatsPicker(accessor: ServicesAccessor, mru?: { readonly backward: boolean }): void { + const sessionsService = accessor.get(ISessionsService); + const quickInputService = accessor.get(IQuickInputService); + const sessionsPartService = accessor.get(ISessionsPartService); + const contextKeyService = accessor.get(IContextKeyService); + const keybindingService = accessor.get(IKeybindingService); + + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const extUri = accessor.get(IUriIdentityService).extUri; + + interface IChatPickItem extends IQuickPickItem { + readonly chat: IChat; + } + + const toItem = (chat: IChat): IChatPickItem => ({ + label: chat.title.get()?.trim() || localize('untitledChat', "Untitled Chat"), + description: fromNow(chat.updatedAt.get(), true, true), + iconClass: ThemeIcon.asClassName(Codicon.commentDiscussion), + chat, + }); + + // MRU mode cycles every open tab (including in-composer drafts) so the set of + // switchable chats matches the SessionHasMultipleOpenChatsContext gate. The + // searchable palette flow instead skips untitled drafts (no meaningful title, + // mirroring the Conversations submenu) and adds the closed chats below. + const openItems = (mru + ? session.visibleChatTabs.get() + : session.visibleChatTabs.get().filter(chat => chat.status.get() !== SessionStatus.Untitled) + ).map(toItem); + // Closed chats are hidden from the tab strip but still reopenable. They are + // only offered in the searchable palette flow — not the Ctrl+Tab MRU switcher, + // which mirrors the editor switcher and cycles open items only. + const closedItems = mru ? [] : session.closedChats.get() + .filter(chat => chat.status.get() !== SessionStatus.Untitled && chat.origin?.kind !== ChatOriginKind.Tool) + .map(toItem); + + // Navigation order: open chats first, then closed chats. + const pickItems = [...openItems, ...closedItems]; + if (pickItems.length === 0) { + return; + } + + const displayItems: (IChatPickItem | IQuickPickSeparator)[] = closedItems.length === 0 + ? openItems + : [ + { type: 'separator', label: localize('openChatsGroup', "Open") }, + ...openItems, + { type: 'separator', label: localize('closedChatsGroup', "Closed") }, + ...closedItems, + ]; + + const activeChat = session.activeChat.get(); + const activeIndex = Math.max(0, activeChat ? pickItems.findIndex(item => extUri.isEqual(item.chat.resource, activeChat.resource)) : -1); + // MRU style starts on the adjacent chat so a single tap+release switches to + // it; palette invocation (non-MRU) focuses the active chat. + const startIndex = mru ? (activeIndex + (mru.backward ? -1 : 1) + pickItems.length) % pickItems.length : activeIndex; + + const disposables = new DisposableStore(); + const picker = disposables.add(quickInputService.createQuickPick({ useSeparators: true })); + picker.items = displayItems; + picker.activeItems = [pickItems[startIndex]]; + if (mru) { + // Editor-switcher style: no filter input, and quick navigate stays active so + // releasing the modifier accepts the focused chat. The modifier is taken + // from the quick-navigate keybinding's chord. + picker.hideInput = true; + picker.quickNavigate = { keybindings: keybindingService.lookupKeybindings(CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID) }; + } else { + // Palette flow: a searchable list across the Open and Closed groups. + picker.placeholder = localize('searchChats', "Search chats by name"); + picker.matchOnDescription = true; + } + + // Expose a context key while the picker is open so the navigate keybindings + // (bound to the same chords) advance the selection instead of re-opening. + const pickerVisibleContext = SessionChatsPickerVisibleContext.bindTo(contextKeyService); + pickerVisibleContext.set(true); + disposables.add(toDisposable(() => pickerVisibleContext.reset())); + + disposables.add(picker.onDidAccept(() => { + const [selected] = picker.selectedItems; + if (selected) { + sessionsService.openChat(session, selected.chat.resource); + sessionsPartService.focusSession(session); + } + picker.hide(); + })); + disposables.add(picker.onDidHide(() => disposables.dispose())); + + picker.show(); +} + +registerAction2(class ShowChatsPickerAction extends Action2 { + constructor() { + super({ + id: SHOW_CHATS_PICKER_COMMAND_ID, + title: localize2('showChatsPicker', "Go to Chat in Session"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleCommittedChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), inQuickPickContext.negate()), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyO, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor); + } +}); + +// Ctrl+Tab / Ctrl+Shift+Tab open the picker in editor-switcher (MRU) mode. Hidden +// from the palette (f1: false) since they only make sense held; the chord wins +// over the session-history secondary via the higher weight while multi-chat. +registerAction2(class QuickSwitchNextChatAction extends Action2 { + constructor() { + super({ + id: QUICK_SWITCH_NEXT_CHAT_ID, + title: localize2('quickSwitchNextChat', "Quick Switch to Next Chat"), + f1: false, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib + 1, + when: ChatsPickerScopeContext, + primary: KeyMod.CtrlCmd | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyCode.Tab }, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor, { backward: false }); + } +}); + +registerAction2(class QuickSwitchPreviousChatAction extends Action2 { + constructor() { + super({ + id: QUICK_SWITCH_PREVIOUS_CHAT_ID, + title: localize2('quickSwitchPreviousChat', "Quick Switch to Previous Chat"), + f1: false, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib + 1, + when: ChatsPickerScopeContext, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.Tab }, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor, { backward: true }); + } +}); + +// While the picker is open, Ctrl+Tab / Ctrl+Shift+Tab cycle forward / backward. +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID, + weight: KeybindingWeight.SessionsContrib + 50, + handler: getQuickNavigateHandler(CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID, true), + when: SessionChatsPickerVisibleContext, + primary: KeyMod.CtrlCmd | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyCode.Tab }, +}); +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID, + weight: KeybindingWeight.SessionsContrib + 50, + handler: getQuickNavigateHandler(CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID, false), + when: SessionChatsPickerVisibleContext, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.Tab }, +}); + export class SessionNewChatActionViewItemContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessions.newChatActionViewItem'; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index dd60bdbdf3d..d63b073a61e 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -1686,10 +1686,20 @@ export class SessionsList extends Disposable implements ISessionsList { updateFindPatternState(); })); - this._register(this._sessionsManagementService.onDidChangeSessions(() => { + this._register(this._sessionsManagementService.onDidChangeSessions(e => { if (this.visible) { this.refresh(); } + // A removed session may have been the last one in its workspace. + // Garbage-collect manual order / promotion entries for identities + // that no longer exist. This runs only on removals (never on + // additions or the initial load) so that asynchronous session + // loading on a window reload can never prune the user's manual + // ordering of workspaces relative to groups before their sessions + // have loaded. + if (e.removed.length > 0) { + this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); + } })); this._register(this._sessionsListModelService.onDidChange(() => { @@ -1698,10 +1708,17 @@ export class SessionsList extends Disposable implements ISessionsList { } })); - this._register(this._sessionGroupsService.onDidChange(() => { + this._register(this._sessionGroupsService.onDidChange(e => { if (this.visible) { this.update(); } + // Garbage-collect manual order / promotion entries when groups are + // deleted or evicted. Group changes are user-driven and happen after + // sessions have loaded, so pruning here is safe (unlike at render + // time during the asynchronous initial load). + if (e.groupsChanged) { + this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); + } })); this._register(this._sessionSectionOrderService.onDidChange(() => { @@ -1801,10 +1818,6 @@ export class SessionsList extends Disposable implements ISessionsList { const sorting = this.options.sorting(); const sortKeyForGrouping = (s: ISession, srt: SessionsSorting) => this._sessionsListModelService.getSortKey(s, sortingToMode(srt)); - // Garbage-collect manual order/promotion entries for groups and - // workspaces that no longer exist (does not affect the visible order). - this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); - // 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 @@ -2230,9 +2243,25 @@ export class SessionsList extends Disposable implements ISessionsList { if (groupSessions.length === 0) { return; } + this._sessionsListModelService.unpinSessions(groupSessions); const group = this._sessionGroupsService.createGroup(localize('newGroupName', "New Group"), groupSessions.map(s => s.sessionId)); this._editingGroupId = group.id; this.update(); + this.revealGroup(group.id); + } + + /** Scroll the group's header into view so its inline name editor is visible. */ + private revealGroup(groupId: string): void { + const root = this.tree.getNode(); + for (const node of root.children) { + const element = node.element; + if (element && isSessionGroupItem(element) && element.group.id === groupId) { + if (this.tree.hasElement(element) && this.tree.getRelativeTop(element) === null) { + this.tree.reveal(element, 0.5); + } + return; + } + } } /** Begin inline renaming of the group's header. */ @@ -2246,9 +2275,8 @@ export class SessionsList extends Disposable implements ISessionsList { addSessionsToGroup(sessions: ISession[], groupId: string, target?: ISession, position?: 'before' | 'after'): void { const groupSessions = sessions.filter(session => !session.isArchived.get()); - for (const session of groupSessions) { - this._sessionGroupsService.addToGroup(session.sessionId, groupId); - } + this._sessionsListModelService.unpinSessions(groupSessions); + this._sessionGroupsService.addToGroup(groupSessions.map(s => s.sessionId), groupId); if (target && position) { this.reorderSessions(groupSessions, target, position); } @@ -2299,14 +2327,15 @@ export class SessionsList extends Disposable implements ISessionsList { * The set of top-level reorder identities that currently exist (every group, * plus every workspace label present across all sessions, regardless of * grouping mode or capping). Used to garbage-collect stale manual order and - * promotion entries. + * promotion entries. Reads sessions fresh from the management service so it + * reflects the latest loaded state even when the list is not visible. */ private liveSectionOrderIds(): Set { const ids = new Set(); for (const group of this._sessionGroupsService.getGroups()) { ids.add(`group:${group.id}`); } - for (const session of this.sessions) { + for (const session of this._sessionsManagementService.getSessions()) { ids.add(`workspace:${sessionWorkspaceLabel(session)}`); } return ids; diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 90bf4a97dfe..29b136b71a0 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { autorun, derived, IReader } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; @@ -16,7 +17,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; -import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; +import { ICommandDetectionCapability, TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; @@ -25,6 +26,7 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionTerminalCounts, ISessionTerminalsProvider, ISessionTerminalsService } from '../../../services/sessions/browser/sessionTerminalsService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -49,7 +51,7 @@ interface ISessionTerminalInfo { * workspace-backed agent sessions. Returns `undefined` for sessions without a * workspace (e.g. Cloud), or when no path is available. */ -function getSessionTerminalInfo(session: ISession | undefined, reader?: IReader): ISessionTerminalInfo | undefined { +export function getSessionTerminalInfo(session: ISession | undefined, reader?: IReader): ISessionTerminalInfo | undefined { if (!session) { return undefined; } @@ -77,7 +79,7 @@ function getSessionTerminalInfo(session: ISession | undefined, reader?: IReader) * - Terminals for archived/removed sessions are hidden/closed using their tracked * session id association while keeping the active terminal protected. */ -export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { +export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution, ISessionTerminalsProvider { static readonly ID = 'workbench.contrib.sessionsTerminal'; @@ -85,6 +87,29 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben private _activeSessionId: string | undefined; private readonly _sessionTerminals = new Map>(); + /** + * Fires when the per-session terminal counts may have changed, so + * {@link ISessionTerminalsService} consumers re-read them. + */ + private readonly _onDidChangeTerminals = this._register(new Emitter()); + readonly onDidChangeTerminals: Event = this._onDidChangeTerminals.event; + + /** + * Per-terminal listeners (child-process busy/idle state, executed text and + * command detection) that affect the session terminal counts. Keyed by + * instance id and disposed when the terminal goes away. + */ + private readonly _terminalListeners = this._register(new DisposableMap()); + + /** + * Instance ids of terminals that have had at least one command sent in them. + * "Command sent" is a sticky, historical fact (a completed command leaves no + * live signal), so it is recorded here once observed — via executed text, + * command detection, an existing command history, or a child process having + * started — rather than recomputed. Drives the "{n} terminals" pill count. + */ + private readonly _terminalsWithCommands = new Set(); + /** * Session ids already processed as archived. The archive cleanup runs only * on the not-archived → archived transition: the provider keeps archived @@ -105,9 +130,23 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @IViewsService viewsService: IViewsService, @IContextKeyService contextKeyService: IContextKeyService, + @ISessionTerminalsService sessionTerminalsService: ISessionTerminalsService, ) { super(); + // Expose this contribution as the terminals provider so the session + // header meta row can show a "{n} terminals" pill without depending on the + // terminal contribution directly. + this._register(sessionTerminalsService.registerProvider(this)); + + // Observe existing and future terminals so the session terminal counts + // update as commands are sent and as processes start/stop. + for (const instance of this._terminalService.instances) { + if (!instance.shellLaunchConfig.hideFromUser) { + this._trackTerminal(instance); + } + } + // Seed with sessions that are already archived (e.g. restored archived // from a previous window) so they are not treated as newly archived on // their first change event. @@ -192,12 +231,15 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._logService.trace(`[SessionsTerminal] Transferred ${terminalIds.size} terminal(s) from session ${from.sessionId} to ${to.sessionId}`); } this._sessionTerminals.delete(from.sessionId); + this._onDidChangeTerminals.fire(); })); // Clean up tracked terminal ids when terminals are externally disposed // (e.g. user closes a terminal tab) so the map doesn't hold stale entries. this._register(this._terminalService.onDidDisposeInstance(instance => { this._removeTerminalFromTrackedSessions(instance.instanceId); + this._terminalListeners.deleteAndDispose(instance.instanceId); + this._terminalsWithCommands.delete(instance.instanceId); })); // Hide restored terminals from a previous window session that don't @@ -208,6 +250,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben if (instance.shellLaunchConfig.hideFromUser) { return; } + this._trackTerminal(instance); if (instance.shellLaunchConfig.attachPersistentProcess && this._activeKey) { instance.getInitialCwd().then(cwd => { if (cwd.toLowerCase() !== this._activeKey) { @@ -219,6 +262,14 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._logService.trace(`[SessionsTerminal] Hid restored terminal ${availableInstance.instanceId} (cwd: ${cwd})`); } }); + } else if (this._activeSessionId) { + // A freshly created (non-restored) terminal in the agents window + // belongs to the active session — its default cwd is kept at the + // active session's working directory. Associate it so the session + // header terminal count reflects it (and so it is cleaned up with + // the session). Restored terminals are excluded above: they are + // matched to their session by cwd instead. + this._trackTerminalsForSession(this._activeSessionId, [instance]); } })); @@ -418,6 +469,104 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben for (const instance of instances) { terminalIds.add(instance.instanceId); } + this._onDidChangeTerminals.fire(); + } + + /** + * Observes the given terminal so the session terminal counts stay current: + * - {@link ITerminalInstance.onDidChangeHasChildProcesses} flips a terminal + * between active (running) and idle. + * - executed text, command detection and a started child process each mark + * the terminal as having had a command sent (see {@link _markTerminalHasCommand}). + * + * Any terminal that already has command history, an executing command or a + * running process when first observed (e.g. one restored from a previous + * window) is seeded as having had a command. + */ + private _trackTerminal(instance: ITerminalInstance): void { + const store = new DisposableStore(); + const id = instance.instanceId; + + // A terminal becoming busy/idle changes the active count; a process + // starting also means a command is running, so it has had a command sent. + store.add(instance.onDidChangeHasChildProcesses(() => { + if (instance.hasChildProcesses) { + this._markTerminalHasCommand(id); + } + this._onDidChangeTerminals.fire(); + })); + + // Text executed programmatically (Run actions, agent task runner) counts + // as a command sent. + store.add(instance.onDidExecuteText(() => this._markTerminalHasCommand(id))); + + // Manual commands (typed + Enter) are surfaced via command detection when + // shell integration is available. The capability may be added after the + // instance is created, so also subscribe once it appears. + const trackCommandDetection = (capability: ICommandDetectionCapability) => { + if (capability.commands.length > 0 || capability.executingCommand) { + this._markTerminalHasCommand(id); + } + store.add(capability.onCommandStarted(() => this._markTerminalHasCommand(id))); + }; + const commandDetection = instance.capabilities.get(TerminalCapability.CommandDetection); + if (commandDetection) { + trackCommandDetection(commandDetection); + } + store.add(instance.capabilities.onDidAddCommandDetectionCapability(trackCommandDetection)); + + // Seed from current state for terminals that already ran something. + if (instance.hasChildProcesses) { + this._markTerminalHasCommand(id); + } + + this._terminalListeners.set(id, store); + } + + /** + * Records that the given terminal has had at least one command sent in it and + * notifies consumers. No-op if already recorded. + */ + private _markTerminalHasCommand(instanceId: number): void { + if (this._terminalsWithCommands.has(instanceId)) { + return; + } + this._terminalsWithCommands.add(instanceId); + this._onDidChangeTerminals.fire(); + } + + /** + * The terminal counts for the given session: the number of tracked terminals + * that have had a command sent in them ({@link ISessionTerminalCounts.total}), + * and of those, the ones currently running something ({@link ISessionTerminalCounts.active}). + * + * This is read from reactive contexts (a `derived` in the header pill and an + * `autorun` in `SessionView`), so it is intentionally non-mutating: it skips + * stale/hidden tracked ids without pruning the tracking map. Cleanup of stale + * entries happens on the actual lifecycle events instead (e.g. + * {@link _removeTerminalFromTrackedSessions} on `onDidDisposeInstance`). + */ + getTerminalCounts(sessionId: string): ISessionTerminalCounts { + const terminalIds = this._sessionTerminals.get(sessionId); + if (!terminalIds) { + return { total: 0, active: 0 }; + } + let total = 0; + let active = 0; + for (const instanceId of terminalIds) { + if (!this._terminalsWithCommands.has(instanceId)) { + continue; + } + const instance = this._terminalService.getInstanceFromId(instanceId); + if (!instance || instance.isDisposed || instance.shellLaunchConfig.hideFromUser) { + continue; + } + total++; + if (instance.hasChildProcesses) { + active++; + } + } + return { total, active }; } private _getTrackedTerminalsForSession(sessionId: string): ITerminalInstance[] { @@ -461,12 +610,18 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } private _removeTerminalFromTrackedSessions(instanceId: number): void { + let changed = false; for (const [sessionId, terminalIds] of this._sessionTerminals) { - terminalIds.delete(instanceId); + if (terminalIds.delete(instanceId)) { + changed = true; + } if (terminalIds.size === 0) { this._sessionTerminals.delete(sessionId); } } + if (changed) { + this._onDidChangeTerminals.fire(); + } } private _getAvailableTerminal(instance: ITerminalInstance, action: string): ITerminalInstance | undefined { diff --git a/src/vs/sessions/contrib/terminal/browser/terminalMetaActions.ts b/src/vs/sessions/contrib/terminal/browser/terminalMetaActions.ts new file mode 100644 index 00000000000..a8ae4860e9f --- /dev/null +++ b/src/vs/sessions/contrib/terminal/browser/terminalMetaActions.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.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 { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; +import { SessionHasTerminalsContext } from '../../../common/contextkeys.js'; +import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { EMPTY_SESSION_TERMINAL_COUNTS, ISessionTerminalCounts, ISessionTerminalsService } from '../../../services/sessions/browser/sessionTerminalsService.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { getSessionTerminalInfo, SessionsTerminalContribution } from './sessionsTerminalContribution.js'; + +// --- Open Terminals action + +class OpenSessionTerminalsAction extends Action2 { + static readonly ID = 'workbench.agentSessions.action.openTerminals'; + + constructor() { + super({ + id: OpenSessionTerminalsAction.ID, + title: localize2('agentSessions.terminals', 'Terminals'), + icon: Codicon.terminal, + f1: false, + // Active-terminals 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 terminal count. + menu: { + id: Menus.SessionHeaderMeta, + group: 'navigation', + order: 2, + when: SessionHasTerminalsContext + }, + }); + } + + override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise { + const sessionsService = accessor.get(ISessionsService); + const viewsService = accessor.get(IViewsService); + const pathService = accessor.get(IPathService); + + // 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(); + + // Ensure the session's terminal is present and shown (the active-session + // change in the terminal contribution surfaces background terminals), then + // reveal the terminal view. + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); + const info = getSessionTerminalInfo(targetSession); + const cwd = info?.cwd ?? await pathService.userHome(); + await contribution.ensureTerminal(cwd, true, targetSession); + await viewsService.openView(TERMINAL_VIEW_ID); + } +} +registerAction2(OpenSessionTerminalsAction); + +// --- Open Terminals action view item (session header active-terminal count) + +/** + * Renders the {@link OpenSessionTerminalsAction} menu item contributed into + * {@link Menus.SessionHeaderMeta} (the session header meta row) as a + * ` {n} terminals` pill. It extends the generic + * {@link SessionHeaderMetaActionViewItem} (so the icon and label render consistently + * with the other meta actions). The label shows the number of the session's terminals + * that have had a command sent in them; the hover additionally reports how many of + * those are currently running something (active). Activating the item reveals the + * terminal view. + * + * The counts are read for the per-surface session published via {@link ISessionContext} + * so the correct session's terminals are reflected even when several session views are + * visible at once. + */ +export class OpenSessionTerminalsActionViewItem extends SessionHeaderMetaActionViewItem { + + private readonly _countsObs: IObservable; + + constructor( + action: MenuItemAction, + options: IActionViewItemOptions, + @ISessionContext sessionContext: ISessionContext, + @ISessionTerminalsService sessionTerminalsService: ISessionTerminalsService, + ) { + super(undefined, action, options); + + const changeSignal = observableSignalFromEvent(this, sessionTerminalsService.onDidChangeTerminals); + this._countsObs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + changeSignal.read(reader); + const sessionId = sessionContext.session.read(reader)?.sessionId; + return sessionId ? sessionTerminalsService.getTerminalCounts(sessionId) : EMPTY_SESSION_TERMINAL_COUNTS; + }); + + this._register(autorun(reader => { + this._countsObs.read(reader); + this.updateLabel(); + this.updateTooltip(); + this.updateAriaLabel(); + })); + } + + protected override getLabelText(): string { + const { total } = this._countsObs.get(); + return total === 1 + ? localize('agentSessions.terminals.one', "{0} terminal", total) + : localize('agentSessions.terminals.many', "{0} terminals", total); + } + + protected override getTooltip(): string { + const { active } = this._countsObs.get(); + return active === 1 + ? localize('agentSessions.terminals.tooltip.one', "{0} active terminal", active) + : localize('agentSessions.terminals.tooltip.many', "{0} active terminals", active); + } + + protected override getAriaLabel(): string { + const { total, active } = this._countsObs.get(); + const totalLabel = total === 1 + ? localize('agentSessions.terminals.one', "{0} terminal", total) + : localize('agentSessions.terminals.many', "{0} terminals", total); + const activeLabel = active === 1 + ? localize('agentSessions.terminals.tooltip.one', "{0} active terminal", active) + : localize('agentSessions.terminals.tooltip.many', "{0} active terminals", active); + // e.g. "Open Terminals: 3 terminals, 1 active terminal" + return localize('agentSessions.terminals.ariaLabel', "Open Terminals: {0}, {1}", totalLabel, activeLabel); + } +} + +/** + * Registers the {@link OpenSessionTerminalsActionViewItem} for the active-terminals + * action in the session header meta toolbar. Registering it here (rather than in the + * core session header) keeps the rendering of the terminal-owned action co-located + * with the action itself. + */ +class OpenSessionTerminalsActionViewItemContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openSessionTerminalsActionViewItem'; + + 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 existing terminals 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, OpenSessionTerminalsAction.ID, (action, options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(OpenSessionTerminalsActionViewItem, action, options); + }, onDidRegister.event)); + onDidRegister.fire(); + } +} + +registerWorkbenchContribution2(OpenSessionTerminalsActionViewItemContribution.ID, OpenSessionTerminalsActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 5c70b36d8f6..5f73835283e 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -11,6 +11,7 @@ import { constObservable, observableValue } from '../../../../../base/common/obs import { IAgentHostTerminalService } from '../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; import { ITerminalProfileService } from '../../../../../workbench/contrib/terminal/common/terminal.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionTerminalsService } from '../../../../services/sessions/browser/sessionTerminalsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -49,6 +50,8 @@ type TestTerminalInstance = ITerminalInstance & { _testCommandHistory: { timestamp: number }[]; _testSetDisposed(disposed: boolean): void; _testSetShellLaunchConfig(shellLaunchConfig: ITerminalInstance['shellLaunchConfig']): void; + _testSetHasChildProcesses(hasChildProcesses: boolean): void; + _testFireExecuteText(): void; }; type TestActiveSession = IActiveSession & { @@ -122,6 +125,7 @@ function makeAgentSession(opts: { sticky: observableValue('test.sticky', false), openChats: observableValue('test.openChats', [chat]), closedChats: constObservable([]), + visibleChatTabs: constObservable([chat]), } satisfies TestActiveSession; return session; } @@ -183,22 +187,38 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT return session; } +/** + * Store that owns the emitters created by {@link makeTerminalInstance}. Assigned + * to the suite store in `setup` so the mock terminals' emitters are disposed on + * teardown without threading the store through every call site. + */ +let terminalMockStore: DisposableStore; + function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance { const commandHistory: { timestamp: number }[] = []; let isDisposed = false; + let hasChildProcesses = false; let shellLaunchConfig: ITerminalInstance['shellLaunchConfig'] = {} as ITerminalInstance['shellLaunchConfig']; + const onDidChangeHasChildProcesses = terminalMockStore.add(new Emitter()); + const onDidExecuteText = terminalMockStore.add(new Emitter()); + const onCommandStarted = terminalMockStore.add(new Emitter()); + const onDidAddCommandDetectionCapability = terminalMockStore.add(new Emitter()); const capabilities = { get(cap: TerminalCapability) { if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) { - return { commands: commandHistory } as unknown as ICommandDetectionCapability; + return { commands: commandHistory, onCommandStarted: onCommandStarted.event } as unknown as ICommandDetectionCapability; } return undefined; - } - } as ITerminalCapabilityStore; + }, + onDidAddCommandDetectionCapability: onDidAddCommandDetectionCapability.event, + } as unknown as ITerminalCapabilityStore; return { instanceId: id, get isDisposed() { return isDisposed; }, + get hasChildProcesses() { return hasChildProcesses; }, + onDidChangeHasChildProcesses: onDidChangeHasChildProcesses.event, + onDidExecuteText: onDidExecuteText.event, get shellLaunchConfig() { return shellLaunchConfig; }, getInitialCwd: () => Promise.resolve(cwd), capabilities, @@ -209,6 +229,13 @@ function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance { _testSetShellLaunchConfig(value: ITerminalInstance['shellLaunchConfig']) { shellLaunchConfig = value; }, + _testSetHasChildProcesses(value: boolean) { + hasChildProcesses = value; + onDidChangeHasChildProcesses.fire(value); + }, + _testFireExecuteText() { + onDidExecuteText.fire(); + }, } as unknown as TestTerminalInstance; } @@ -256,6 +283,7 @@ suite('SessionsTerminalContribution', () => { defaultCwdCalls = []; logService = new TestLogService(); allSessions = []; + terminalMockStore = store; instantiationService = store.add(new TestInstantiationService()); @@ -298,6 +326,10 @@ suite('SessionsTerminalContribution', () => { if (disposeOnCreatePaths.has(cwdStr)) { instance._testSetDisposed(true); terminalInstances.delete(id); + } else { + // The real terminal service fires onDidCreateInstance for new + // terminals; mirror that so the contribution observes them. + onDidCreateInstance.fire(instance); } return instance; } @@ -349,6 +381,10 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + instantiationService.stub(ISessionTerminalsService, new class extends mock() { + override registerProvider() { return Disposable.None; } + }); + instantiationService.stub(IViewsService, new class extends mock() { override isViewVisible(): boolean { return false; } override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event; @@ -1239,6 +1275,101 @@ suite('SessionsTerminalContribution', () => { // rather than left in the background assert.ok(showBackgroundCalls.includes(restoredTerminal.instanceId), 'untracked restored terminal at matching cwd should be shown'); }); + + // --- Terminal counts (session header meta pill) --- + + test('getTerminalCounts excludes empty terminals and counts running ones as active', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ sessionId: 'test:count', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + // A terminal was created and tracked, but no command has been sent in it. + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 0, active: 0 }); + + // Sending a command makes it count toward the total, but it is only active + // while something is running in it. + const instance = [...terminalInstances.values()][0] as TestTerminalInstance; + instance._testFireExecuteText(); + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 1, active: 0 }); + + // A long-running command (e.g. a watch task) keeps it active until it ends. + instance._testSetHasChildProcesses(true); + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 1, active: 1 }); + + instance._testSetHasChildProcesses(false); + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 1, active: 0 }); + }); + + test('a running child process alone counts the terminal as having a command', async () => { + const session = makeAgentSession({ sessionId: 'test:child', worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + const instance = [...terminalInstances.values()][0] as TestTerminalInstance; + instance._testSetHasChildProcesses(true); + assert.deepStrictEqual(contribution.getTerminalCounts('test:child'), { total: 1, active: 1 }); + }); + + test('getTerminalCounts is scoped per session and aggregates multiple terminals', async () => { + const sessionA = makeAgentSession({ sessionId: 'test:a', worktree: URI.file('/a'), providerType: AgentSessionProviders.Background }); + + // Session A is active and gets its initial terminal with a command sent. + activeSessionObs.set(sessionA, undefined); + await tick(); + const a1 = [...terminalInstances.values()][0] as TestTerminalInstance; + a1._testFireExecuteText(); + + // The user creates a second terminal in the agents window while session A + // is active; it is associated with the active session. A command is sent + // in it and it keeps running. + const a2 = makeTerminalInstance(nextInstanceId++, '/a'); + terminalInstances.set(a2.instanceId, a2); + onDidCreateInstance.fire(a2); + a2._testFireExecuteText(); + a2._testSetHasChildProcesses(true); + + assert.deepStrictEqual(contribution.getTerminalCounts('test:a'), { total: 2, active: 1 }); + // A session with no tracked terminals reports zero. + assert.deepStrictEqual(contribution.getTerminalCounts('test:b'), { total: 0, active: 0 }); + }); + + test('creating a new terminal and running a command updates the counts', async () => { + const session = makeAgentSession({ sessionId: 'test:new', worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + // First terminal: a finished command (counts toward total, not active). + const first = [...terminalInstances.values()][0] as TestTerminalInstance; + first._testFireExecuteText(); + assert.deepStrictEqual(contribution.getTerminalCounts('test:new'), { total: 1, active: 0 }); + + // A long-running command keeps the first terminal active. + first._testSetHasChildProcesses(true); + + // Creating a new terminal and running a command in it bumps both counts. + const second = makeTerminalInstance(nextInstanceId++, '/worktree'); + terminalInstances.set(second.instanceId, second); + onDidCreateInstance.fire(second); + second._testFireExecuteText(); + second._testSetHasChildProcesses(true); + + assert.deepStrictEqual(contribution.getTerminalCounts('test:new'), { total: 2, active: 2 }); + }); + + test('fires onDidChangeTerminals when a command is sent in a session terminal', async () => { + const session = makeAgentSession({ sessionId: 'test:fire', worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + let fired = 0; + store.add(contribution.onDidChangeTerminals(() => fired++)); + + const instance = [...terminalInstances.values()][0] as TestTerminalInstance; + instance._testFireExecuteText(); + + assert.ok(fired > 0, 'expected onDidChangeTerminals to fire when a command is sent'); + }); }); function tick(): Promise { diff --git a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts index dd09e468fc7..af676ae7800 100644 --- a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts @@ -74,6 +74,12 @@ export interface ISessionGroupsService { /** Add a session to a group (removing it from any previous group). */ addToGroup(sessionId: string, groupId: string): void; + /** + * Add multiple sessions to a group at once (removing them from any previous + * group), firing a single change event. + */ + addToGroup(sessionIds: Iterable, groupId: string): void; + /** Remove a session from its group, if any. */ removeFromGroup(sessionId: string): void; @@ -263,12 +269,18 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe this._onDidChange.fire({ groupsChanged: true, membershipChanged }); } - addToGroup(sessionId: string, groupId: string): void { - if (!this._groups.has(groupId) || this._membership.get(sessionId) === groupId) { + addToGroup(sessionIdOrIds: string | Iterable, groupId: string): void { + if (!this._groups.has(groupId)) { return; } + const sessionIds = typeof sessionIdOrIds === 'string' ? [sessionIdOrIds] : sessionIdOrIds; const membershipChanged = new Set(); - this.setMembership(sessionId, groupId, membershipChanged); + for (const sessionId of sessionIds) { + this.setMembership(sessionId, groupId, membershipChanged); + } + if (membershipChanged.size === 0) { + return; + } const evicted = this.evictExcessEmptyGroups(); this.save(); this._onDidChange.fire({ groupsChanged: evicted, membershipChanged }); diff --git a/src/vs/sessions/services/sessions/browser/sessionTerminalsService.ts b/src/vs/sessions/services/sessions/browser/sessionTerminalsService.ts new file mode 100644 index 00000000000..1dd634e701a --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionTerminalsService.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * 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, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const ISessionTerminalsService = createDecorator('sessionTerminalsService'); + +/** + * The terminal counts surfaced for a session in the header meta pill. + */ +export interface ISessionTerminalCounts { + /** + * The number of terminals associated with the session that have had at least + * one command sent in them. Empty terminals that never ran a command are + * excluded. Drives the pill label and visibility. + */ + readonly total: number; + + /** + * The number of those terminals that are currently running something (have a + * live child process), e.g. an in-progress `npm install` or a watch task. + * Drives the pill hover. Always `<= total`. + */ + readonly active: number; +} + +export const EMPTY_SESSION_TERMINAL_COUNTS: ISessionTerminalCounts = { total: 0, active: 0 }; + +/** + * Backing data source for {@link ISessionTerminalsService}. Implemented by the + * sessions terminal contribution, which owns the per-session terminal tracking, + * and registered via {@link ISessionTerminalsService.registerProvider}. + * + * The contribution lives in the `contrib` layer, so it cannot be depended upon + * directly by lower-layer consumers (e.g. the `SessionView` that sets the + * `SessionHasTerminalsContext` key). This provider indirection inverts the + * dependency: the contribution registers itself into the service instead. + */ +export interface ISessionTerminalsProvider { + + /** Fires when the terminal counts for one or more sessions may have changed. */ + readonly onDidChangeTerminals: Event; + + /** See {@link ISessionTerminalsService.getTerminalCounts}. */ + getTerminalCounts(sessionId: string): ISessionTerminalCounts; +} + +/** + * Exposes the terminal counts (terminals with a command, and of those, the ones + * currently running) associated with a session, so surfaces such as the session + * header meta row can show a "{n} terminals" pill without depending on the + * terminal contribution directly. + */ +export interface ISessionTerminalsService { + + readonly _serviceBrand: undefined; + + /** Fires when the terminal counts for one or more sessions may have changed. */ + readonly onDidChangeTerminals: Event; + + /** + * The terminal counts for the given session. Returns + * {@link EMPTY_SESSION_TERMINAL_COUNTS} when no provider is registered. + */ + getTerminalCounts(sessionId: string): ISessionTerminalCounts; + + /** + * Registers the backing provider. Only one provider is supported at a time; + * registering a second provider while one is active throws. Dispose the + * returned disposable to unregister. + */ + registerProvider(provider: ISessionTerminalsProvider): IDisposable; +} + +export class SessionTerminalsService extends Disposable implements ISessionTerminalsService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeTerminals = this._register(new Emitter()); + readonly onDidChangeTerminals: Event = this._onDidChangeTerminals.event; + + private _provider: ISessionTerminalsProvider | undefined; + + getTerminalCounts(sessionId: string): ISessionTerminalCounts { + return this._provider?.getTerminalCounts(sessionId) ?? EMPTY_SESSION_TERMINAL_COUNTS; + } + + registerProvider(provider: ISessionTerminalsProvider): IDisposable { + if (this._provider) { + throw new Error('A session terminals provider is already registered'); + } + this._provider = provider; + const listener = provider.onDidChangeTerminals(() => this._onDidChangeTerminals.fire()); + + // A provider registering may change the answer for any session (from the + // no-provider default), so announce the change once on registration. + this._onDidChangeTerminals.fire(); + + return toDisposable(() => { + listener.dispose(); + if (this._provider === provider) { + this._provider = undefined; + this._onDidChangeTerminals.fire(); + } + }); + } +} + +registerSingleton(ISessionTerminalsService, SessionTerminalsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts index 71fa8ca3848..bf3b3b33289 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts @@ -47,6 +47,7 @@ export interface ISessionsListModelService { pinSession(session: ISession): void; unpinSession(session: ISession): void; + unpinSessions(sessions: ISession[]): void; isSessionPinned(session: ISession): boolean; // -- Read/Unread -- @@ -182,6 +183,19 @@ export class SessionsListModelService extends Disposable implements ISessionsLis this._onDidChange.fire({ changes: [{ sessionId: session.sessionId, kind: SessionListModelChangeKind.Pinned }] }); } + unpinSessions(sessions: ISession[]): void { + const changed: { sessionId: string; kind: SessionListModelChangeKind }[] = []; + for (const session of sessions) { + if (this._pinnedSessionIds.delete(session.sessionId)) { + changed.push({ sessionId: session.sessionId, kind: SessionListModelChangeKind.Pinned }); + } + } + if (changed.length > 0) { + this.saveSet(SessionsListModelService.PINNED_SESSIONS_KEY, this._pinnedSessionIds); + this._onDidChange.fire({ changes: changed }); + } + } + isSessionPinned(session: ISession): boolean { return this._pinnedSessionIds.has(session.sessionId); } diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 9999cdc721e..e7a0b811a9f 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -8,7 +8,7 @@ import { IObservable, ISettableObservable, ITransaction, autorun, derived, obser import { URI } from '../../../../base/common/uri.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IActiveSession } from '../common/sessionsManagement.js'; -import { IChat, ISession, SessionStatus } from '../common/session.js'; +import { IChat, ISession, ChatOriginKind, SessionStatus } from '../common/session.js'; /** * Wraps an {@link ISession} with an active chat observable to form an @@ -32,10 +32,20 @@ export class VisibleSession extends Disposable implements IActiveSession { private readonly _activeChat: ISettableObservable; readonly activeChat: IObservable; + /** + * Model and mode are scoped to the active chat so the Agents window pickers + * read and write the selection of the currently focused chat, not the + * session/default chat. Sessions with multiple peer chats keep an + * independent model/agent per chat. + */ + private readonly _activeChatModelId: IObservable; + private readonly _activeChatMode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; + /** Resource strings of chats that have been closed (hidden from the tab strip). */ private readonly _closedChatUris: ISettableObservable>; readonly openChats: IObservable; readonly closedChats: IObservable; + readonly visibleChatTabs: IObservable; constructor( private readonly _session: ISession, @@ -46,6 +56,9 @@ export class VisibleSession extends Disposable implements IActiveSession { this._activeChat = observableValue(`activeChat-${_session.sessionId}`, initialChat); this.activeChat = this._activeChat; + this._activeChatModelId = derived(this, reader => this._activeChat.read(reader).modelId.read(reader)); + this._activeChatMode = derived(this, reader => this._activeChat.read(reader).mode.read(reader)); + // Seed the closed set from persisted state, but never hide the chat that // is being restored as active, nor the main chat (which can never be // closed and must always remain in the tab strip). @@ -72,6 +85,11 @@ export class VisibleSession extends Disposable implements IActiveSession { } return this._session.chats.read(reader).filter(c => closed.has(c.resource.toString())); }); + // Tab strip contents: the open chats with tool-origin chats (subagents) + // hidden, in the provider's order. + this.visibleChatTabs = derived(this, reader => + this.openChats.read(reader).filter(c => c.origin?.kind !== ChatOriginKind.Tool) + ); } setActiveChat(chat: IChat): void { @@ -131,8 +149,8 @@ export class VisibleSession extends Disposable implements IActiveSession { get status() { return this._session.status; } get changes() { return this._session.changes; } get changesets() { return this._session.changesets; } - get modelId() { return this._session.modelId; } - get mode() { return this._session.mode; } + get modelId() { return this._activeChatModelId; } + get mode() { return this._activeChatMode; } get loading() { return this._session.loading; } get isArchived() { return this._session.isArchived; } get isRead() { return this._session.isRead; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 67381be7306..c489750278f 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -151,6 +151,40 @@ export interface ISessionChangesSummary { export type ISessionFileChange = IChatSessionFileChange | IChatSessionFileChange2; +/** + * The kind of change applied to a {@link ISessionFile}. + * + * A file that is first created and then edited during the session is reported + * as {@link Created}. A file that is deleted is reported as {@link Deleted} + * regardless of any earlier creation or edit. + */ +export const enum SessionFileOperation { + /** The file was created during the session (and possibly edited afterwards). */ + Created = 'created', + /** The file existed before the session and was modified during it. */ + Modified = 'modified', + /** The file was deleted during the session. */ + Deleted = 'deleted', +} + +/** + * A file that was created, edited or deleted **outside** the session workspace + * folders during the session. These are surfaced separately from + * {@link ISession.changes} because they are not part of the workspace and will + * not be committed. + */ +export interface ISessionFile { + /** The file URI (after-state for create/modify, the deleted path for delete). */ + readonly uri: URI; + /** The kind of change applied to the file during the session. */ + readonly operation: SessionFileOperation; + /** + * URI from which the file's pre-session content can be read, when known. + * Used to render a diff for {@link SessionFileOperation.Modified} files. + */ + readonly originalUri?: URI; +} + /** * Well-known id of the changeset that holds the diff between a session's branch * and its base (e.g. `main...feature`). Shared so that consumers which always @@ -350,6 +384,13 @@ export interface ISession { readonly changes: IObservable; /** Changesets produced by the session. */ readonly changesets: IObservable; + /** + * Files created, edited or deleted **outside** the session workspace folders + * during the session (e.g. config files in the user's home directory). These + * are not part of {@link changes} and will not be committed. Providers that + * cannot determine this report an empty array (or omit the observable). + */ + readonly externalChanges?: IObservable; /** Currently selected model identifier. */ readonly modelId: IObservable; readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 23736a0a95e..0bd5be0e32e 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { SessionHasChangesContext, @@ -22,6 +23,7 @@ import { SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, + SessionActiveChatIsClosableContext, } from '../../../common/contextkeys.js'; import { ChatOriginKind, ISession, SessionStatus } from './session.js'; import { IActiveSession } from './sessionsManagement.js'; @@ -46,6 +48,7 @@ interface ISessionContextKeys { readonly sticky: IContextKey; readonly hasMultipleCommittedChats: IContextKey; readonly hasMultipleOpenChats: IContextKey; + readonly activeChatIsClosable: IContextKey; } /** @@ -78,6 +81,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey sticky: SessionIsStickyContext.bindTo(contextKeyService), hasMultipleCommittedChats: SessionHasMultipleCommittedChatsContext.bindTo(contextKeyService), hasMultipleOpenChats: SessionHasMultipleOpenChatsContext.bindTo(contextKeyService), + activeChatIsClosable: SessionActiveChatIsClosableContext.bindTo(contextKeyService), }; boundKeysByService.set(contextKeyService, keys); } @@ -151,4 +155,14 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, // More than one open chat (incl. drafts) means the tab strip is shown; the // header then hides its own New Chat button. keys.hasMultipleOpenChats.set((session?.openChats.read(reader).filter(chat => chat.origin?.kind !== ChatOriginKind.Tool).length ?? 0) > 1); + + // The active chat can be closed/deleted from the tab strip only when it is a + // real, non-main chat (the main chat lives and dies with its session). + const activeChat = session?.activeChat.read(reader); + const mainResource = session?.mainChat.read(reader).resource; + keys.activeChatIsClosable.set( + !!activeChat && !!mainResource + && !isEqual(activeChat.resource, mainResource) + && activeChat.origin?.kind !== ChatOriginKind.Tool + ); } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index f501bc4a64d..81c506e73f8 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -118,6 +118,12 @@ export interface IActiveSession extends ISession { /** The closed (hidden from the tab strip) but still reopenable chats. Deleted chats drop out. */ readonly closedChats: IObservable; + + /** + * The chats shown as tabs in the tab strip: {@link openChats} with tool-origin + * chats (subagents) hidden, in the provider's order. + */ + readonly visibleChatTabs: IObservable; } /** diff --git a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts index eb107d39765..694c7925575 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts @@ -104,6 +104,29 @@ suite('SessionGroupsService', () => { assert.deepStrictEqual(service.getSessionIdsInGroup(b.id), ['s1']); }); + test('addToGroup adds multiple sessions in a single change event', () => { + const a = service.createGroup('A'); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.addToGroup(['s1', 's2', 's3'], a.id); + + assert.deepStrictEqual( + [service.getSessionIdsInGroup(a.id), changeCount], + [['s1', 's2', 's3'], 1] + ); + }); + + test('addToGroup with multiple sessions does not fire when none change', () => { + const a = service.createGroup('A', ['s1', 's2']); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.addToGroup(['s1', 's2'], a.id); + + assert.strictEqual(changeCount, 0); + }); + test('remove from group clears membership', () => { const a = service.createGroup('A', ['s1', 's2']); service.removeFromGroup('s1'); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 36994f65956..f1af8336a2f 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -123,6 +123,7 @@ class MockSessionStore implements ISessionsManagementService { activeChat: observableValue(`test.activeChat-${session.sessionId}`, activeChat), openChats: session.chats, closedChats: constObservable([]), + visibleChatTabs: session.chats, }; this.activeSession.set(active, undefined); } else { diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts index b2af6a14839..6e7c148236a 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts @@ -120,6 +120,34 @@ suite('SessionsListModelService', () => { assert.strictEqual(service.isSessionPinned(s2), false); }); + test('unpinSessions unpins multiple sessions and fires once', () => { + const s1 = createSession('s1'); + const s2 = createSession('s2'); + const s3 = createSession('s3'); + service.pinSession(s1); + service.pinSession(s2); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.unpinSessions([s1, s2, s3]); + + assert.deepStrictEqual( + [service.isSessionPinned(s1), service.isSessionPinned(s2), changeCount], + [false, false, 1] + ); + }); + + test('unpinSessions does not fire when none are pinned', () => { + const s1 = createSession('s1'); + const s2 = createSession('s2'); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.unpinSessions([s1, s2]); + + assert.strictEqual(changeCount, 0); + }); + // -- Read/Unread -- test('markRead marks session as read', () => { @@ -362,7 +390,7 @@ suite('SessionsListModelService', () => { service.markRead(session); // Make session the active one - activeSession.set({ ...session, activeChat: constObservable(session.mainChat.get()), isCreated: constObservable(true), sticky: constObservable(false), openChats: session.chats, closedChats: constObservable([]) }, undefined); + activeSession.set({ ...session, activeChat: constObservable(session.mainChat.get()), isCreated: constObservable(true), sticky: constObservable(false), openChats: session.chats, closedChats: constObservable([]), visibleChatTabs: session.chats }, undefined); // Seed the last-known status as InProgress sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index cb67a82dd90..6e441e4cf4a 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -12,7 +12,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { VisibleSession, VisibleSessions } from '../../browser/visibleSessions.js'; -import { IChat, ISession } from '../../common/session.js'; +import { ChatOriginKind, IChat, ISession, SessionStatus } from '../../common/session.js'; const stubChat: IChat = { resource: URI.parse('test:///chat'), @@ -1023,3 +1023,70 @@ suite('VisibleSession - open/close chats', () => { }); }); }); + +suite('VisibleSession - visibleChatTabs', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChat(id: string, status = SessionStatus.Completed, origin?: ChatOriginKind): IChat { + return { + ...stubChat, + resource: URI.parse(`test:///chat/${id}`), + title: constObservable(id), + status: constObservable(status), + origin: origin ? { kind: origin } : undefined, + }; + } + + function createSession(chats: IChat[]) { + const base = stubSession('S'); + const session: ISession = { ...base, chats: constObservable(chats), mainChat: constObservable(chats[0]) }; + return disposables.add(new VisibleSession(session, chats[0])); + } + + test('keeps provider order and hides tool-origin chats', () => { + const visible = createSession([ + makeChat('main'), + makeChat('draft', SessionStatus.Untitled), + makeChat('tool', SessionStatus.Completed, ChatOriginKind.Tool), + makeChat('second'), + ]); + + assert.deepStrictEqual(visible.visibleChatTabs.get().map(c => c.title.get()), ['main', 'draft', 'second']); + }); +}); + +suite('VisibleSession - per-chat model/mode', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChat(id: string, modelId: string | undefined, modeId: string | undefined): IChat { + return { + ...stubChat, + resource: URI.parse(`test:///chat/${id}`), + title: constObservable(id), + modelId: constObservable(modelId), + mode: constObservable(modeId ? { id: modeId, kind: 'agent' } : undefined), + }; + } + + test('modelId and mode follow the active chat, not the session/default chat', () => { + const first = makeChat('first', 'model-1', 'agent-1'); + const second = makeChat('second', 'model-2', 'agent-2'); + const base = stubSession('S'); + const session: ISession = { ...base, chats: constObservable([first, second]), mainChat: constObservable(first) }; + const visible = disposables.add(new VisibleSession(session, first)); + + assert.deepStrictEqual( + { modelId: visible.modelId.get(), mode: visible.mode.get() }, + { modelId: 'model-1', mode: { id: 'agent-1', kind: 'agent' } }, + ); + + visible.setActiveChat(second); + + assert.deepStrictEqual( + { modelId: visible.modelId.get(), mode: visible.mode.get() }, + { modelId: 'model-2', mode: { id: 'agent-2', kind: 'agent' } }, + ); + }); +}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index c52f82dda0f..3c95dceb231 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -478,6 +478,7 @@ import './contrib/browserView/browser/sessionBrowserView.contribution.js'; import './contrib/editor/browser/editor.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; +import './contrib/terminal/browser/terminalMetaActions.js'; import './contrib/chatDebug/browser/chatDebug.contribution.js'; import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/aquarium/browser/aquarium.contribution.js'; diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index d40a0c0e844..34d2fd7a4ca 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2209,6 +2209,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseProgressPart2: extHostTypes.ChatResponseProgressPart2, ChatResponseThinkingProgressPart: extHostTypes.ChatResponseThinkingProgressPart, ChatResponseHookPart: extHostTypes.ChatResponseHookPart, + ChatResponseAutoModeResolutionPart: extHostTypes.ChatResponseAutoModeResolutionPart, ChatResponseReferencePart: extHostTypes.ChatResponseReferencePart, ChatResponseReferencePart2: extHostTypes.ChatResponseReferencePart, ChatResponseCodeCitationPart: extHostTypes.ChatResponseCodeCitationPart, diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 5fcf02a0741..a580f8cbc06 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -399,6 +399,7 @@ export class ChatAgentResponseStream { part instanceof extHostTypes.ChatResponseExternalEditPart || part instanceof extHostTypes.ChatResponseThinkingProgressPart || part instanceof extHostTypes.ChatResponsePullRequestPart || + part instanceof extHostTypes.ChatResponseAutoModeResolutionPart || part instanceof extHostTypes.ChatResponseProgressPart2 ) { checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); @@ -413,6 +414,9 @@ export class ChatAgentResponseStream { } else if (part instanceof extHostTypes.ChatResponseThinkingProgressPart) { const dto = typeConvert.ChatResponseThinkingProgressPart.from(part); _report(dto); + } else if (part instanceof extHostTypes.ChatResponseAutoModeResolutionPart) { + const dto = typeConvert.ChatResponseAutoModeResolutionPart.from(part); + _report(dto); } else if (part instanceof extHostTypes.ChatResponseAnchorPart) { const dto = typeConvert.ChatResponseAnchorPart.from(part); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 5100ecf840a..b770f348bfc 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -43,7 +43,7 @@ import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; @@ -2840,6 +2840,26 @@ export namespace ChatResponseHookPart { } } +export namespace ChatResponseAutoModeResolutionPart { + const validLabels = new Set(['needs_reasoning', 'no_reasoning', 'fallback']); + + export function from(part: vscode.ChatResponseAutoModeResolutionPart): Dto { + const label = validLabels.has(part.predictedLabel as IChatAutoModeResolutionPart['predictedLabel']) + ? part.predictedLabel as IChatAutoModeResolutionPart['predictedLabel'] + : 'fallback'; + return { + kind: 'autoModeResolution', + resolvedModel: part.resolvedModel, + resolvedModelName: part.resolvedModelName, + predictedLabel: label, + confidence: Math.max(0, Math.min(1, part.confidence)), + }; + } + export function to(part: Dto): vscode.ChatResponseAutoModeResolutionPart { + return new types.ChatResponseAutoModeResolutionPart(part.resolvedModel, part.resolvedModelName, part.predictedLabel, part.confidence); + } +} + export namespace ChatResponseWarningPart { export function from(part: vscode.ChatResponseWarningPart): Dto { return { @@ -3384,6 +3404,8 @@ export namespace ChatResponsePart { return ChatToolInvocationPart.from(part); } else if (part instanceof types.ChatResponseWorkspaceEditPart) { return ChatResponseWorkspaceEditPart.from(part); + } else if (part instanceof types.ChatResponseAutoModeResolutionPart) { + return ChatResponseAutoModeResolutionPart.from(part); } return { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index e3ea32f2087..40d6b2496d2 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3270,6 +3270,19 @@ export class ChatResponseHookPart { } } +export class ChatResponseAutoModeResolutionPart { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: string; + confidence: number; + constructor(resolvedModel: string, resolvedModelName: string, predictedLabel: string, confidence: number) { + this.resolvedModel = resolvedModel; + this.resolvedModelName = resolvedModelName; + this.predictedLabel = predictedLabel; + this.confidence = confidence; + } +} + export class ChatResponseWarningPart { value: vscode.MarkdownString; constructor(value: string | vscode.MarkdownString) { diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 560f202f027..673663c7c5d 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -843,7 +843,7 @@ class PolicyDiagnosticsAction extends Action2 { // Reuse the same precedence as policy evaluation so this report can never drift from the // source AccountPolicyService actually applies. - const selection = selectManagedSettings(serverManagedSettings, nativeManagedSettings, fileManagedSettings); + const selection = selectManagedSettings(nativeManagedSettings, serverManagedSettings, fileManagedSettings); content += `**Active source**: ${managedSettingsSourceLabel(selection.source)}\n\n`; @@ -852,6 +852,16 @@ class PolicyDiagnosticsAction extends Action2 { // jsonc-style: accumulate every error instead of failing on the first. const parseErrors: { stage: string; message: string }[] = []; + // Sections are listed in precedence order (highest first): native MDM wins over the + // server endpoint, which in turn wins over the file on disk. + content += '### Native MDM\n\n'; + content += PROPERTY_VALUE_TABLE_HEADER; + content += `| Available | ${nativeManagedSettingsService ? 'yes' : 'no'} |\n`; + content += `| Active | ${selection.source === 'nativeMdm' ? 'yes' : 'no'} |\n\n`; + if (nativeManagedSettingsService) { + content += jsonBlock(nativeManagedSettings); + } + content += '### GitHub Server API\n\n'; content += PROPERTY_VALUE_TABLE_HEADER; content += '| Endpoint | `/copilot_internal/managed_settings` |\n'; @@ -871,14 +881,6 @@ class PolicyDiagnosticsAction extends Action2 { content += '**Normalized bag**\n\n'; content += jsonBlock(serverManagedSettings); - content += '### Native MDM\n\n'; - content += PROPERTY_VALUE_TABLE_HEADER; - content += `| Available | ${nativeManagedSettingsService ? 'yes' : 'no'} |\n`; - content += `| Active | ${selection.source === 'nativeMdm' ? 'yes' : 'no'} |\n\n`; - if (nativeManagedSettingsService) { - content += jsonBlock(nativeManagedSettings); - } - content += '### File (managed-settings.json)\n\n'; content += PROPERTY_VALUE_TABLE_HEADER; content += `| Available | ${fileManagedSettingsService ? 'yes' : 'no'} |\n`; diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index f7701c3972b..d5da4b4de33 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -1902,7 +1902,9 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi !this.isVisible(Parts.STATUSBAR_PART) ? LayoutClasses.STATUSBAR_HIDDEN : undefined, this.state.runtime.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.isShadowsDisabled() ? LayoutClasses.NO_SHADOWS : undefined, - this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined + this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined, + `panel-position-${positionToString(this.getPanelPosition())}`, + `panel-alignment-${this.getPanelAlignment()}` ]); } @@ -2034,7 +2036,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // panel alignment requires the editor part to be visible this.setAuxiliaryBarMaximized(false); + // Adjust CSS — capture old value before updating state model + const oldAlignmentValue = this.getPanelAlignment(); this.stateModel.setRuntimeValue(LayoutStateKeys.PANEL_ALIGNMENT, alignment); + this.mainContainer.classList.remove(`panel-alignment-${oldAlignmentValue}`); + this.mainContainer.classList.add(`panel-alignment-${alignment}`); this.adjustPartPositions(this.getSideBarPosition(), alignment, this.getPanelPosition()); @@ -2366,6 +2372,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi const panelContainer = assertReturnsDefined(panelPart.getContainer()); panelContainer.classList.remove(oldPositionValue); panelContainer.classList.add(newPositionValue); + this.mainContainer.classList.remove(`panel-position-${oldPositionValue}`); + this.mainContainer.classList.add(`panel-position-${newPositionValue}`); // Update Styles panelPart.updateStyles(); diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 7f28295d02d..d92b36a5869 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -48,6 +48,29 @@ margin-top: 0; } +/* + * When panel is at TOP and sidebars are in the same grid row as the editor (sibling), + * give them a top margin matching the editor's gap from the panel. Alignment determines + * which bars are sibling — mirrors adjustPartPositions() in layout.ts. + * Uses `.panel-alignment-*` and `.left`/`.right` classes set in layout.ts. + */ +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-justify .part.sidebar, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-justify .part.auxiliarybar, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-left .part.sidebar.left, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-left .part.auxiliarybar.left, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-right .part.sidebar.right, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-right .part.auxiliarybar.right { + margin-top: var(--vscode-spacing-size40); +} + +/* Panels in top/left/right positions are flush with the title bar (no top margin). + * The panel element itself carries the position class (e.g. `.top`). */ +.monaco-workbench.floating-panels .part.panel.top, +.monaco-workbench.floating-panels .part.panel.left, +.monaco-workbench.floating-panels .part.panel.right { + margin-top: 0; +} + /* * 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 @@ -68,6 +91,13 @@ margin-top: 0; } +/* When the panel is at the top and visible the editor sits below it — give it a top margin + * so the inter-card gap matches the gaps between the other floating cards. + * Requires `.panel-position-top` on `.monaco-workbench` (set in layout.ts). */ +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel) > .monaco-grid-view .part.editor { + margin-top: var(--vscode-spacing-size40); +} + /* * 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 @@ -129,3 +159,49 @@ .monaco-workbench.floating-panels .activitybar > .content > .composite-bar { margin-top: var(--vscode-spacing-size20); } + +/* + * When the status bar is hidden every floating card sits directly against the window + * bottom edge. Double the bottom margin (4px → 8px) to match the doubled outer gutter + * applied to other window-edge sides, so all gaps look consistent. + * Exceptions: + * - Panel at TOP: its bottom faces the editor (not the window edge) — keep 4px. + * `.part.panel.top` is intentionally absent from the selector list below. + * - Editor when a bottom panel is visible: it abuts the panel card — keep 4px. + * The code-side insets in AbstractPaneCompositePart and EditorPart are kept in sync. + */ +.monaco-workbench.floating-panels.nostatusbar .part.panel.bottom, +.monaco-workbench.floating-panels.nostatusbar .part.panel.left, +.monaco-workbench.floating-panels.nostatusbar .part.panel.right, +.monaco-workbench.floating-panels.nostatusbar .part.sidebar, +.monaco-workbench.floating-panels.nostatusbar .part.auxiliarybar { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} + +.monaco-workbench.floating-panels.nostatusbar > .monaco-grid-view .part.editor { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} + +/* + * When a visible bottom panel directly abuts the sidebars and/or editor (they share + * the same grid row), keep the normal 4px inter-card gap instead of the doubled 8px. + * Which bars are in the same row as the editor depends on panel alignment: + * justify → both sidebars are in the top row (above the full-width panel) + * left → the bar on the LEFT is in the top row; the bar on the RIGHT is full-height + * right → the bar on the RIGHT is in the top row; the bar on the LEFT is full-height + * center → neither sidebar is in the top row (both span full height) + * Uses `.panel-alignment-*` and `.left`/`.right` position classes set in layout.ts. + */ +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-justify .part.sidebar, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-justify .part.auxiliarybar, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-left .part.sidebar.left, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-left .part.auxiliarybar.left, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-right .part.sidebar.right, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-right .part.auxiliarybar.right, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel) > .monaco-grid-view .part.editor { + margin-bottom: var(--vscode-spacing-size40); +} + +.monaco-workbench.floating-panels.nostatusbar .part.activitybar { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 771cb6b3951..4cb9e60f6d7 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1392,7 +1392,8 @@ export class EditorPart extends Part implements IEditorPart, 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); + const { topMargin, bottomMargin } = this.getFloatingPanelHeightInsets(); + height = Math.max(0, height - topMargin - bottomMargin); // Reserve space for the Modern UI editor border (styleOverrides/media/editorBorder.css) so content doesn't get clipped. if (!this.element.classList.contains('modal-editor-part')) { @@ -1413,6 +1414,26 @@ export class EditorPart extends Part implements IEditorPart, this.doLayout(Dimension.lift(contentAreaSize), top, left); } + /** + * Returns the top and bottom margins (in pixels) to subtract from the editor height + * when the floating panels experiment is active. Accounts for panel position (a top + * panel pushes the editor down) and status bar visibility (hidden status bar means + * the editor is at the window bottom edge and gets a doubled bottom margin). + */ + private getFloatingPanelHeightInsets(): { topMargin: number; bottomMargin: number } { + const panelVisible = this.layoutService.isVisible(Parts.PANEL_PART); + // When the panel is positioned above the editor and visible, the editor is no longer + // adjacent to the title bar — reserve a top margin to match the inter-card gaps. + const panelAtTop = panelVisible && this.layoutService.getPanelPosition() === Position.TOP; + // When the status bar is hidden, the editor is at the window bottom edge — double the + // margin. Exception: when a bottom panel is visible the editor's bottom faces the panel + // card (not the window edge), so keep the normal inter-card gap. + const panelAtBottom = panelVisible && this.layoutService.getPanelPosition() === Position.BOTTOM; + const bottomMargin = !this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow) && !panelAtBottom + ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; + return { topMargin: panelAtTop ? FLOATING_PANEL_MARGIN : 0, bottomMargin }; + } + private doLayout(dimension: Dimension, top = this.top, left = this.left): void { this._contentDimension = dimension; diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 9a6763399ae..0f828260077 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, getFloatingOuterGutterEdges } from '../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts, Position, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges, getFloatingSidebarSiblingToEditorStatus } 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'; @@ -639,6 +639,58 @@ export abstract class AbstractPaneCompositePart extends CompositePart