diff --git a/.eslint-plugin-local/code-no-icons-in-localized-strings.ts b/.eslint-plugin-local/code-no-icons-in-localized-strings.ts new file mode 100644 index 00000000000..8f4251dfd41 --- /dev/null +++ b/.eslint-plugin-local/code-no-icons-in-localized-strings.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import { TSESTree } from '@typescript-eslint/utils'; + +/** + * Prevents theme icon syntax `$(iconName)` from appearing inside localized + * string arguments. Localizers may translate or corrupt the icon syntax, + * breaking rendering. Icon references should be kept outside the localized + * string - either prepended via concatenation or passed as a placeholder + * argument. + * + * Examples: + * ❌ localize('key', "$(gear) Settings") + * ✅ '$(gear) ' + localize('key', "Settings") + * ✅ localize('key', "Like {0}", '$(gear)') + * + * ❌ nls.localize('key', "$(loading~spin) Loading...") + * ✅ '$(loading~spin) ' + nls.localize('key', "Loading...") + */ +export default new class NoIconsInLocalizedStrings implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noIconInLocalizedString: 'Theme icon syntax $(…) should not appear inside localized strings. Move it outside the localize call or pass it as a placeholder argument.' + }, + docs: { + description: 'Prevents $(icon) theme icon syntax inside localize() string arguments', + }, + type: 'problem', + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + // Matches $(iconName) or $(iconName~modifier) but not escaped \$(...) + const iconPattern = /(? checkCallExpression(node as TSESTree.CallExpression) + }; + } +}; diff --git a/.eslint-plugin-local/code-no-telemetry-common-property.ts b/.eslint-plugin-local/code-no-telemetry-common-property.ts new file mode 100644 index 00000000000..2627a09c0a4 --- /dev/null +++ b/.eslint-plugin-local/code-no-telemetry-common-property.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import type * as ESTree from 'estree'; + +const telemetryMethods = new Set(['publicLog', 'publicLog2', 'publicLogError', 'publicLogError2']); + +/** + * Common telemetry property names that are automatically added to every event. + * Telemetry events must not set these because they would collide with / be + * overwritten by the common properties that the telemetry pipeline injects. + * + * Collected from: + * - src/vs/platform/telemetry/common/commonProperties.ts (resolveCommonProperties) + * - src/vs/workbench/services/telemetry/common/workbenchCommonProperties.ts + * - src/vs/workbench/services/telemetry/browser/workbenchCommonProperties.ts + */ +const commonTelemetryProperties = new Set([ + 'common.machineid', + 'common.sqmid', + 'common.devdeviceid', + 'sessionid', + 'commithash', + 'version', + 'common.releasedate', + 'common.platformversion', + 'common.platform', + 'common.nodeplatform', + 'common.nodearch', + 'common.product', + 'common.msftinternal', + 'timestamp', + 'common.timesincesessionstart', + 'common.sequence', + 'common.snap', + 'common.platformdetail', + 'common.version.shell', + 'common.version.renderer', + 'common.firstsessiondate', + 'common.lastsessiondate', + 'common.isnewsession', + 'common.remoteauthority', + 'common.cli', + 'common.useragent', + 'common.istouchdevice', +]); + +export default new class NoTelemetryCommonProperty implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noCommonProperty: 'Telemetry events must not contain the common property "{{name}}". Common properties are automatically added by the telemetry pipeline and will be dropped.', + }, + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + /** + * Check whether any property key in an object expression is a reserved common telemetry property. + */ + function checkObjectForCommonProperties(node: ESTree.ObjectExpression) { + for (const prop of node.properties) { + if (prop.type === 'Property') { + let name: string | undefined; + if (prop.key.type === 'Identifier') { + name = prop.key.name; + } else if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') { + name = prop.key.value; + } + if (name && commonTelemetryProperties.has(name.toLowerCase())) { + context.report({ + node: prop.key, + messageId: 'noCommonProperty', + data: { name }, + }); + } + } + } + } + + return { + ['CallExpression[callee.property.type="Identifier"]'](node: ESTree.CallExpression) { + const callee = node.callee; + if (callee.type !== 'MemberExpression') { + return; + } + const prop = callee.property; + if (prop.type !== 'Identifier' || !telemetryMethods.has(prop.name)) { + return; + } + // The data argument is the second argument for publicLog/publicLog2/publicLogError/publicLogError2 + const dataArg = node.arguments[1]; + if (dataArg && dataArg.type === 'ObjectExpression') { + checkObjectForCommonProperties(dataArg); + } + }, + }; + } +}; diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d56465c45a..54502973051 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -145,6 +145,7 @@ function f(x: number, y: string): void { } - You MUST NOT use storage keys of another component only to make changes to that component. You MUST come up with proper API to change another component. - Use `IEditorService` to open editors instead of `IEditorGroupsService.activeGroup.openEditor` to ensure that the editor opening logic is properly followed and to avoid bypassing important features such as `revealIfOpened` or `preserveFocus`. - Avoid using `bind()`, `call()` and `apply()` solely to control `this` or partially apply arguments; prefer arrow functions or closures to capture the necessary context, and use these methods only when required by an API or interoperability. +- Avoid using events to drive control flow between components. Instead, prefer direct method calls or service interactions to ensure clearer dependencies and easier traceability of logic. Events should be reserved for broadcasting state changes or notifications rather than orchestrating behavior across components. ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. diff --git a/.github/instructions/remoteAgentHost.instructions.md b/.github/instructions/remoteAgentHost.instructions.md new file mode 100644 index 00000000000..aa3f24b290e --- /dev/null +++ b/.github/instructions/remoteAgentHost.instructions.md @@ -0,0 +1,35 @@ +--- +description: Architecture documentation for remote agent host connections. Use when working in `src/vs/sessions/contrib/remoteAgentHost` +applyTo: src/vs/sessions/contrib/remoteAgentHost/** +--- + +# Remote Agent Host + +The remote agent host feature connects the sessions app to agent host processes running on other machines over WebSocket. + +## Key Files + +- `ARCHITECTURE.md` - full architecture documentation (URI conventions, registration flow, data flow diagram) +- `REMOTE_AGENT_HOST_RECONNECTION.md` - reconnection lifecycle spec (15 numbered requirements) +- `browser/remoteAgentHost.contribution.ts` - central orchestrator +- `browser/agentHostFileSystemProvider.ts` - read-only FS provider for remote browsing + +## Architecture Documentation + +When making changes to this feature area, **review and update `ARCHITECTURE.md`** if your changes affect: + +- Connection lifecycle (connect, disconnect, reconnect) +- Agent registration flow +- URI conventions or naming +- Session creation flow +- The data flow diagram + +The doc lives at `src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md`. + +## Related Code Outside This Folder + +- `src/vs/platform/agentHost/common/remoteAgentHostService.ts` - service interface (`IRemoteAgentHostService`) +- `src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` - Electron implementation +- `src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` - WebSocket protocol client +- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` - session list sidebar +- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` - session content provider diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md index 9dbef3ce12f..3781f160e76 100644 --- a/.github/prompts/fix-error.prompt.md +++ b/.github/prompts/fix-error.prompt.md @@ -23,16 +23,32 @@ After the fix is validated (compilation clean, tests pass): 1. **Create a branch**: `git checkout -b /` (e.g., `bryanchen-d/fix-notebook-index-error`). 2. **Commit**: Stage changed files and commit with a message like `fix: (#)`. 3. **Push**: `git push -u origin `. -4. **Create a draft PR** with a description that includes: - - A summary of the change. - - `Fixes #` so GitHub auto-closes the issue when the PR merges. - - What scenarios may trigger the error. - - The code flow explaining why the error gets thrown and goes unhandled. - - Steps a user can follow to manually validate the fix. - - How the fix addresses the issue, with a brief note per changed file. -5. **Monitor the PR** for Copilot review comments. Wait 1-2 minutes after each push for Copilot to leave its review, then check for new comments. Evaluate each comment: - - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). - - If not applicable, leave a reply explaining why. - - After addressing comments, update the PR description if the changes affect the summary, code flow explanation, or per-file notes. -6. **Repeat monitoring** after each push: wait 1-2 minutes, check for new Copilot comments, and address them. Continue this loop until no new comments appear. -7. **Re-run tests** after addressing review comments to confirm nothing regressed. +4. **Create a draft PR** with a description that includes these sections: + - **Summary**: A concise description of what was changed and why. + - **Issue link**: `Fixes #` so GitHub auto-closes the issue when the PR merges. + - **Trigger scenarios**: What user actions or system conditions cause this error to surface. + - **Code flow diagram**: A Mermaid swimlane/sequence diagram showing the call chain from trigger to error. Use participant labels for the key components (e.g., classes, modules, processes). Example: + ```` + ```mermaid + sequenceDiagram + participant A as CallerComponent + participant B as MiddleLayer + participant C as LowLevelUtil + A->>B: someOperation(data) + B->>C: validate(data) + C-->>C: data is invalid + C->>B: throws "error message" + B->>A: unhandled error propagates + ``` + ```` + - **Manual validation steps**: Concrete, step-by-step instructions a reviewer can follow to reproduce the original error and verify the fix. Include specific setup requirements (e.g., file types to open, settings to change, actions to perform). If the error cannot be easily reproduced manually, explain why and describe what alternative validation was performed (e.g., unit tests, code inspection). + - **How the fix works**: A brief explanation of the fix approach, with a note per changed file. +5. **Monitor the PR — BLOCKING**: You MUST NOT complete the task until the monitoring loop below is done. + - Wait 2 minutes after each push, then check for Copilot review comments using `gh pr view --json reviews,comments` and `gh api repos/{owner}/{repo}/pulls/{number}/comments`. + - If there are review comments, evaluate each one: + - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). + - If not applicable, leave a reply explaining why. + - After addressing comments, update the PR description if the changes affect the summary, diagram, or per-file notes. + - **Re-run tests** after addressing review comments to confirm nothing regressed. + - After each push, repeat the wait-and-check cycle. Continue until **two consecutive checks return zero new comments**. +6. **Verify CI**: After the monitoring loop is done, check that CI checks are passing using `gh pr checks `. If any required checks fail, investigate and fix. Do NOT complete the task with failing CI. diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md new file mode 100644 index 00000000000..b90fb5b46cf --- /dev/null +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -0,0 +1,42 @@ +--- +name: chat-customizations-editor +description: Use when working on the Chat Customizations editor — the management UI for agents, skills, instructions, hooks, prompts, MCP servers, and plugins. +--- + +# Chat Customizations Editor + +Split-view management pane for AI customization items across workspace, user, extension, and plugin storage. Supports harness-based filtering (Local, Copilot CLI, Claude). + +## Spec + +**`src/vs/sessions/AI_CUSTOMIZATIONS.md`** — always read before making changes, always update after. + +## Key Folders + +| Folder | What | +|--------|------| +| `src/vs/workbench/contrib/chat/common/` | `ICustomizationHarnessService`, `ISectionOverride`, `IStorageSourceFilter` — shared interfaces and filter helpers | +| `src/vs/workbench/contrib/chat/browser/aiCustomization/` | Management editor, list widgets (prompts, MCP, plugins), harness service registration | +| `src/vs/sessions/contrib/chat/browser/` | Sessions-window overrides (harness service, workspace service) | +| `src/vs/sessions/contrib/sessions/browser/` | Sessions tree view counts and toolbar | + +When changing harness descriptor interfaces or factory functions, verify both core and sessions registrations compile. + +## Key Interfaces + +- **`IHarnessDescriptor`** — drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference. +- **`ISectionOverride`** — per-section button customization (command invocation, root file creation, type labels, file extensions). +- **`IStorageSourceFilter`** — controls which storage sources and user roots are visible per harness/type. + +Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code. + +## Testing + +Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. + +```bash +./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts" +npm run compile-check-ts-native && npm run valid-layers-check +``` + +See the `sessions` skill for sessions-window specific guidance. diff --git a/.github/skills/unit-tests/SKILL.md b/.github/skills/unit-tests/SKILL.md new file mode 100644 index 00000000000..f2a8b66c5a3 --- /dev/null +++ b/.github/skills/unit-tests/SKILL.md @@ -0,0 +1,87 @@ +--- +name: unit-tests +description: Use when running unit tests in the VS Code repo. Covers the runTests tool, scripts/test.sh (macOS/Linux) and scripts/test.bat (Windows), and their supported arguments for filtering, globbing, and debugging tests. +--- + +# Running Unit Tests + +## Preferred: Use the `runTests` tool + +If the `runTests` tool is available, **prefer it** over running shell commands. It provides structured output with detailed pass/fail information and supports filtering by file and test name. + +- Pass absolute paths to test files via the `files` parameter. +- Pass test names via the `testNames` parameter to filter which tests run. +- Set `mode="coverage"` to collect coverage. + +Example (conceptual): run tests in `src/vs/editor/test/common/model.test.ts` with test name filter `"should split lines"`. + +## Fallback: Shell scripts + +When the `runTests` tool is not available (e.g. in CLI environments), use the platform-appropriate script from the repo root: + +- **macOS / Linux:** `./scripts/test.sh [options]` +- **Windows:** `.\scripts\test.bat [options]` + +These scripts download Electron if needed and launch the Mocha test runner. + +### Commonly used options + +#### `--run ` - Run tests from a specific file + +Accepts a **source file path** (starting with `src/`). The runner strips the `src/` prefix and the `.ts`/`.js` extension automatically to resolve the compiled module. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts +``` + +Multiple files can be specified by repeating `--run`: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --run src/vs/editor/test/common/range.test.ts +``` + +#### `--grep ` (aliases: `-g`, `-f`) - Filter tests by name + +Runs only tests whose full title matches the pattern (passed to Mocha's `--grep`). + +```bash +./scripts/test.sh --grep "should split lines" +``` + +Combine with `--run` to filter tests within a specific file: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --grep "should split lines" +``` + +#### `--runGlob ` (aliases: `--glob`, `--runGrep`) - Run tests matching a glob + +Runs all test files matching a glob pattern against the compiled output directory. Useful for running all tests under a feature area. + +```bash +./scripts/test.sh --runGlob "**/editor/test/**/*.test.js" +``` + +Note: the glob runs against compiled `.js` files in the output directory, not source `.ts` files. + +#### `--coverage` - Generate a coverage report + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --coverage +``` + +#### `--timeout ` - Set test timeout + +Override the default Mocha timeout for long-running tests. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --timeout 10000 +``` + +### Integration tests + +Integration tests (files ending in `.integrationTest.ts` or located in `extensions/`) are **not run** by `scripts/test.sh`. Use `scripts/test-integration.sh` (or `scripts/test-integration.bat`) instead. + +### Compilation requirement + +Tests run against compiled JavaScript output. Ensure the `VS Code - Build` watch task is running or that compilation has completed before running tests. Test failures caused by stale output are a common pitfall. diff --git a/.npmrc b/.npmrc index 6df04ca0e7e..2e08f5efcdd 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.8.3" -ms_build_id="13586709" +target="39.8.2" +ms_build_id="13563792" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts index 8927b0b7064..f21e36604fb 100644 --- a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts +++ b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts @@ -112,7 +112,7 @@ export class NpmUpToDateFeature extends vscode.Disposable { } try { const script = path.join(workspaceRoot, 'build', 'npm', 'installStateHash.ts'); - const output = cp.execFileSync(process.execPath, [script], { + const output = cp.execFileSync(process.execPath, [script, '--ignore-node-version'], { cwd: workspaceRoot, timeout: 10_000, encoding: 'utf8', diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index e3ddd3af411..b6e40685d5a 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.112.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.113.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index c4bc569e9da..a41aa7f69b7 100644 --- a/.vscode/notebooks/my-work.github-issues +++ b/.vscode/notebooks/my-work.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"March 2026\"\n" + "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"1.113.0\"\n" }, { "kind": 1, diff --git a/build/azure-pipelines/win32/product-build-win32-cli.yml b/build/azure-pipelines/win32/product-build-win32-cli.yml index 78461a959ed..20e49d34866 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli.yml @@ -120,6 +120,9 @@ jobs: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: ✍️ Codesign + - powershell: Remove-Item -Path "$(Build.ArtifactStagingDirectory)/sign/CodeSignSummary*.md" -Force -ErrorAction SilentlyContinue + displayName: Remove CodeSignSummary + - task: ArchiveFiles@2 displayName: Archive signed CLI inputs: diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 3a0e930f3f4..4364e9bfc3e 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -0ab48e3e8888b5c33950be0c36da939aa989df7609d3c32140c5e5371ea53abb *chromedriver-v39.8.3-darwin-arm64.zip -b7103565ffb4068dc705c50ce3039ed3178cac350301abf82545de54ac3bc849 *chromedriver-v39.8.3-darwin-x64.zip -e7e43ee7a3d14482ce488d0b0bc338a026a00ee544e5a3d55aed220af6b5da0e *chromedriver-v39.8.3-linux-arm64.zip -060223baebe6d8f9e8c7367bf561dd558fca03509edcc3bce660c42f96ad73ea *chromedriver-v39.8.3-linux-armv7l.zip -854a6f921684e59866aed9db0e9f61d28f756f70b7898f947359b4d04dba76db *chromedriver-v39.8.3-linux-x64.zip -f70ea58bc5e4e51eec51f65e153cfd36eea568ecd571c2815a4c05a457b6923d *chromedriver-v39.8.3-mas-arm64.zip -8e3e1450bc544bff712ffab0ba365d1ed2c9b79116b4ec4750a46c8607242ed4 *chromedriver-v39.8.3-mas-x64.zip -c07e35a2a5a673c8902452571f3436ca8b774fa4628ad9e42f179d3c935f4ed7 *chromedriver-v39.8.3-win32-arm64.zip -d0361344208d8bdf58500d08ae1bb723b9ccdc66fc736c2fc6c9f011bcc6e47d *chromedriver-v39.8.3-win32-ia32.zip -e2e91fd7d97e3e9806d22c4990253cbd5e466cdfa1a8e4c86c72431f7d3a8d0f *chromedriver-v39.8.3-win32-x64.zip -f004c879e159edf3eb403bd43bc76c3069b0b375c6dfae5b249b96d543e51e26 *electron-api.json -21a5324aaed782fead97b2e50f833373148392d4c13ec818f80f142e800c6158 *electron-v39.8.3-darwin-arm64-dsym-snapshot.zip -bb9c14900f48aabb7d272149ba4b60813b366f1e67f95b510da73355e15ba78c *electron-v39.8.3-darwin-arm64-dsym.zip -8a42b50a0841e7bfefc49e704f5cfdb3cbb7b9a507ac74b9982004a9350a202d *electron-v39.8.3-darwin-arm64-symbols.zip -e1b9e03a56fc27ad567c8d2bb32a21e0e2afe6a095f71c26df5b8b8ed8dd8d4c *electron-v39.8.3-darwin-arm64.zip -5b474116e398286a80def6509fa255481ab88fbb52b1770dfd5d39ddff124c6b *electron-v39.8.3-darwin-x64-dsym-snapshot.zip -14648a98eef5a28c1158f0580a813617d9ce6d77a8b7881c389acfff34d328cd *electron-v39.8.3-darwin-x64-dsym.zip -231e13b26c39cceecec359e74c00e4d6a13de3ae9fb6459f18846f91f214074f *electron-v39.8.3-darwin-x64-symbols.zip -22cf6f6147d5d632e2a8ad5207504a18db94a8c96e3f4f65f792822eaed7bf1c *electron-v39.8.3-darwin-x64.zip -fdf25df8857e1ef2cdb0a5be71b78dfb9923a6061cf11336577c6a4368ecfdcd *electron-v39.8.3-linux-arm64-debug.zip -731bf3f908a1efe871e862852087b67027c791427284f057d42376634d4d53d3 *electron-v39.8.3-linux-arm64-symbols.zip -e1a0e6939fe2d10c1f807888f74dbbb9f28a2cfc25e28bb8168f5513513fc124 *electron-v39.8.3-linux-arm64.zip -65893fd03097eadb0c89eb95ded97e97a9910bbc53634f12170cfb40b9165832 *electron-v39.8.3-linux-armv7l-debug.zip -997acce3540d16f9e0551cde811021999a4276c970bfe42ed77c3fd769ba6d05 *electron-v39.8.3-linux-armv7l-symbols.zip -5d5825966a3b2678c50121c81ed3fb8c39d35c3798dd0413a19afaac04109ef9 *electron-v39.8.3-linux-armv7l.zip -52b44ef60f73ef7b7c8461f520a1048da3601d9cc869262ec63f507cd6591e78 *electron-v39.8.3-linux-x64-debug.zip -c4e1fa21d21724ab7f5bcdb6c1bfc03dca837ebcca00d6af56944041499d35a9 *electron-v39.8.3-linux-x64-symbols.zip -5866d6c6f8fcf15967279107d2387edfa4589c5a00ad52d4b770d7504106a734 *electron-v39.8.3-linux-x64.zip -3ff4c9fb99f40dda465486fa6fa23eaedd89b87dcd9cce402a171accfcacc9bd *electron-v39.8.3-mas-arm64-dsym-snapshot.zip -9401101eabaf5e55063b9fad94bf3ac2fe9d743ff88ec638a3c6c665b2266564 *electron-v39.8.3-mas-arm64-dsym.zip -0905e57da501b64436dff51b1378bae311cb1276372dc39dedb7aef44f1b947a *electron-v39.8.3-mas-arm64-symbols.zip -1af2cdee3405c0b8e1c8145a65891b249ac737dd35d959cebd6833970ad5eb08 *electron-v39.8.3-mas-arm64.zip -9e8c6b7b880ac726cda52aaf2b02bc9f0750559be85ef1583789d52b617914b9 *electron-v39.8.3-mas-x64-dsym-snapshot.zip -483ce280606a61c7ed4394e99008ad6fc7e2ce9c35149c5ad745bce9ee78a7a2 *electron-v39.8.3-mas-x64-dsym.zip -1e16529ab1ee8404b1623df611077abcbbbabc1f825a57e93e2ef2b1f7ba788a *electron-v39.8.3-mas-x64-symbols.zip -19df399b352db2c3b3f26f830700b17adf697b70e4d361b1e0f20790e6e703b6 *electron-v39.8.3-mas-x64.zip -bdfd01e7c55ea4beb90afd4285ab3639e3a66808ec993389e9eaf62c3edcb5e9 *electron-v39.8.3-win32-arm64-pdb.zip -7329264c9d308a78081509cb4173f0bd931522655d2f434ad858555e735e5721 *electron-v39.8.3-win32-arm64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-arm64-toolchain-profile.zip -699933ff8c4d7216fc0318f239a5f593f06487c0dc9c3722b8744f6a44fca94e *electron-v39.8.3-win32-arm64.zip -1ffab5a8419a1a93476e2edc09754a52bbe9f3d39915e097f2a1ee50ffdbbd13 *electron-v39.8.3-win32-ia32-pdb.zip -6445047728d64a09db80205c24135f140ba60c25433d833f581c57092638b875 *electron-v39.8.3-win32-ia32-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-ia32-toolchain-profile.zip -b80bb96a4eda2c2b6bd223d2d8b6abfc39abdebac0b36cf74cd70661d43258a5 *electron-v39.8.3-win32-ia32.zip -c8e3cab205bdfe42f916a4428fe0a5e88b6f90e8482e297dadfe1234420abb8f *electron-v39.8.3-win32-x64-pdb.zip -eda4a2f01e388eccfc2ecc7587b0987d123ae01e5b832b73a0a76bb62680bd7c *electron-v39.8.3-win32-x64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-x64-toolchain-profile.zip -12eabd7c5f08823525034c1ff3ab286f271af802928e0f224b458235e2689c5a *electron-v39.8.3-win32-x64.zip -d5345fc0cb336a425f7a25885f67969452746cbf30cc1e95449f7a68221aab07 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.3-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.3-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.3-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-mas-x64.zip -06d402e51bf66fd1a0eddc7e8329b31eb8a1bc6c829e5bc13694708a010feb07 *ffmpeg-v39.8.3-win32-arm64.zip -4b1c2ddedebbf32b7792fb788ddce577f2dc6f8ecccd913e72e842068b2f81e5 *ffmpeg-v39.8.3-win32-ia32.zip -0cf0f521a452d6fdfe1313b81284d646e991954e91c356d805f5c066f2ecf278 *ffmpeg-v39.8.3-win32-x64.zip -bb2d6ec64c43e5a41da5bc55a3daa2b7ef8a0dee722dc73f4fa31bcae5487cb2 *hunspell_dictionaries.zip -7fd663a5eeaf4d0a93785ea4ea715d21464c70c1341b7d8629c96a7bfe24044a *libcxx-objects-v39.8.3-linux-arm64.zip -bdd7e9f732b97b6be2c8293d9391bce3a5cd60feae1c762a0dda0790493da7a2 *libcxx-objects-v39.8.3-linux-armv7l.zip -0de009f84fed7c1bba087ff161674177ca91951fca2f4c60850be0bffb42dfdf *libcxx-objects-v39.8.3-linux-x64.zip -8ae7fd5c3bdc332f9f49830a9316e470d43f17e6ad6adbd05ac629d03d1718c2 *libcxx_headers.zip -9b988e2bb379c6d3094872f600944ad3284510cf225f86368c4f43270b89673c *libcxxabi_headers.zip -d504296ed183e3f460028a73b4a5e2bcd99bc4a3c74b8dc73ba987719c005458 *mksnapshot-v39.8.3-darwin-arm64.zip -b18b8a0e902cf86961d53486826fb07feb3ac98e018b2849cf2bb13150077b13 *mksnapshot-v39.8.3-darwin-x64.zip -7d9dc2ceb3f88d8d532af5b90387479ade571a0370489429571871d386c12322 *mksnapshot-v39.8.3-linux-arm64-x64.zip -84114ba67259f52ae462210544e815b602d70b71162f7f70982a7c36db54b4fa *mksnapshot-v39.8.3-linux-armv7l-x64.zip -be08392f0964d2166bb84212d225c5380fde9b12e622599cb040f45524ff7882 *mksnapshot-v39.8.3-linux-x64.zip -ffebb01a6fe568ec51391f9585e8abf1a93a566a7c991b2abacc33cc2e94d705 *mksnapshot-v39.8.3-mas-arm64.zip -858a8ee80b6f826b1d24a9458b8acb0fcc9805ee3c309652d60ed5c07b578113 *mksnapshot-v39.8.3-mas-x64.zip -dbc82c573f1ba098b6d321ad79c6580f27066d94e13fd93c0b7650a54150eb5c *mksnapshot-v39.8.3-win32-arm64-x64.zip -c8f7c43741b20da99558db596d32c86ebff3483aaf6b3e2539cd85a471b92043 *mksnapshot-v39.8.3-win32-ia32.zip -000941eeb8e1169d581120df7d42aa0b76e33de99a06528a9c4767bcffb74cbf *mksnapshot-v39.8.3-win32-x64.zip +0f8398b79fb1d6a0036be18c24caef2d48dab9e8980ff6a7f0f658e11df86ca0 *chromedriver-v39.8.2-darwin-arm64.zip +f9995e244e0c703b0c1e06bcad2b1b9feca79d4437901e3b9dfa1f635b03884b *chromedriver-v39.8.2-darwin-x64.zip +45083a530bd03781dd759720519c805c046f392d88e2404268392446f896e265 *chromedriver-v39.8.2-linux-arm64.zip +09a6548e5abc4e1589870031bf35edb00b506da10102bb5d1b52fc069b7c1b34 *chromedriver-v39.8.2-linux-armv7l.zip +713570bbe7877fa950cbb533197cfb12aa7ff85d4db7e1fc9ad6ac57ca5733c9 *chromedriver-v39.8.2-linux-x64.zip +66a0109f235f0dec7d05d95f67f3ab07edebfd3e919d093ce71115484d2cfea2 *chromedriver-v39.8.2-mas-arm64.zip +d124f6440f2ff6de9c26f8764ad461cb8daec8e150699006d2ece850f1ff7125 *chromedriver-v39.8.2-mas-x64.zip +89c57558bf892492f5945415c20dc34cf7836661ed82f0f5816081a9e85b6859 *chromedriver-v39.8.2-win32-arm64.zip +2f5452b92dd26d0262329be08ad185bee3e9ce73536337df961e2a36273e99a9 *chromedriver-v39.8.2-win32-ia32.zip +d18fcd1ee0e2905ea8775470e956cd8ccd357f5e790169820bac26b5d5e5f540 *chromedriver-v39.8.2-win32-x64.zip +b8a2b1464313aa4e3d3e70ba84604879a1e2f21b654ef1feedc244eff294e46f *electron-api.json +e2a63aff66cfae22037682db1b3bdbeb616c9070eb56eac8f0cca58ff67168dd *electron-v39.8.2-darwin-arm64-dsym-snapshot.zip +8eca78b4b567cf258a4cfe6f9277060fbc1533dffe494936cc407453d68afe1a *electron-v39.8.2-darwin-arm64-dsym.zip +540715f221cf9c286c2ba30013bae3900595950e3e32fd7650b670a70f82d472 *electron-v39.8.2-darwin-arm64-symbols.zip +1910b2b857e0ee6d2ebd57ead75c3ace7d367a6bb9ccd6a48f8d2b23d93ffe67 *electron-v39.8.2-darwin-arm64.zip +491c9092487835661006c7d9665f3293ba547af1379ea779458cc7c3a79665a0 *electron-v39.8.2-darwin-x64-dsym-snapshot.zip +4e6a5a65947b7cec21571e8cac7afcd4d548c7c98c42c1107a47451fb35b1057 *electron-v39.8.2-darwin-x64-dsym.zip +942688360848bcf4b371553096e0ad77627acbb92eeea2426ba7885c7e4949b6 *electron-v39.8.2-darwin-x64-symbols.zip +9d80221dd2621a9526047be09379e32bbfc9dd57331e41bc0826aadbb69f632a *electron-v39.8.2-darwin-x64.zip +6ca8338548b63198143e25d9be34fa729763b82b68401b4112a787cf1d08ef60 *electron-v39.8.2-linux-arm64-debug.zip +12e1cae738ee45020249c7f15a3c2fb379425f0c8b6226ce7d3f53db356f3a82 *electron-v39.8.2-linux-arm64-symbols.zip +856848216c549a783b39f8d84dd93668d71da0d804e3bba709265804e5b4ba94 *electron-v39.8.2-linux-arm64.zip +a4b19bc2da1d531c6e689c2ac82af1453d45883197021ac8fd4f25029a9cf995 *electron-v39.8.2-linux-armv7l-debug.zip +e3be10fea936d22abaf70371c093d732a330ed639931ceeb04865edbce4c48bc *electron-v39.8.2-linux-armv7l-symbols.zip +56602fe1579eec07d810389ccf3d10c3d50e994f0319048f4f3057f8b24aa97b *electron-v39.8.2-linux-armv7l.zip +89d9a1c4dd9e632ebb1d7f816e003d152d58722f4b1849ff962df2330aa55edc *electron-v39.8.2-linux-x64-debug.zip +c5ad596d3017e4b2e5c8dd8fe7b7fbfaeca97505462c157f10ceedb1782c8cc2 *electron-v39.8.2-linux-x64-symbols.zip +3977017548b5dfdf78e1342cbe251c7ee7a127e52514903e181fa92143b0fa3a *electron-v39.8.2-linux-x64.zip +559f513006663e18e65dc936c6f50add34cda8fc0d639fd861daf354f017d293 *electron-v39.8.2-mas-arm64-dsym-snapshot.zip +d98d80c47d06169790a68bc72f652c5300235d93612787a97580a3ec6201ff03 *electron-v39.8.2-mas-arm64-dsym.zip +7862973f21e05dc5619110e789ddfc8b3973ae482a3dbcd6f33dfe42c939dc3d *electron-v39.8.2-mas-arm64-symbols.zip +a7dacb1566e909407510437d030904825ad88829186dc31e364d5b7a747b4fc6 *electron-v39.8.2-mas-arm64.zip +ecb9de6b8d7c564e5b0e62f5153d9c61272ef49e7f186fb986c58e225172c2b2 *electron-v39.8.2-mas-x64-dsym-snapshot.zip +3044fa159f9ff6b9c53311a4b4561b726f544240ae9452e1aa8505ccdb08a457 *electron-v39.8.2-mas-x64-dsym.zip +1b5e30679a43faee9a671b67b7c04b9ac464469aa493ce3c14cb8f52debec0f2 *electron-v39.8.2-mas-x64-symbols.zip +dcb094d185447f8bef67c3bf5537b47c61a80e077c2482d4a1a1289121c7cad0 *electron-v39.8.2-mas-x64.zip +d8475aef9e0e5f8f77fb9e3e9547656ec4c7688a432957686761ea8a19fa1a92 *electron-v39.8.2-win32-arm64-pdb.zip +1113ba8fc6dbebbad1a6eb0c0ba3f14698e0b99c16aa9b8cf6d637408f01646a *electron-v39.8.2-win32-arm64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-arm64-toolchain-profile.zip +d3d478f30002a70da0bf02775436b5f865345b9a25d0e0b75e1b089560bbf7fd *electron-v39.8.2-win32-arm64.zip +2d67e61dce2d50d291305df43e7bd312c2c665b71257d71e5c8cfab6c0ec931e *electron-v39.8.2-win32-ia32-pdb.zip +b5dd932b5ca51089dec5b9830554dfda5abdcee6a3bbd69f8531ae76a27524e6 *electron-v39.8.2-win32-ia32-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-ia32-toolchain-profile.zip +fd8270cb5ba43193d32a371263fa0cf73d112534ab852867fb86d10c6a82db39 *electron-v39.8.2-win32-ia32.zip +31f069c1ebdf46d3dc6704157e3ed60d707aec414f391b9993c6918c0b8ae0fd *electron-v39.8.2-win32-x64-pdb.zip +c732d314d4a7f44c20bcaeb6bc12a74947e7f28e16426fdbe041c9a35759e76f *electron-v39.8.2-win32-x64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-x64-toolchain-profile.zip +e5b2c8bda64b65e6587c2f3c97f48857fd02ab894bb7a6e4c73bd4a5bcc10416 *electron-v39.8.2-win32-x64.zip +179d2bf1b64e27cda05128656ff6bbbbd80eaf8b2ff04de3ae0999b850362785 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.2-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.2-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.2-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-mas-x64.zip +a0b525af0aa198214ba3c29a0b41297b15618fdad8c4f5aa3c42cf6a6ab80bfa *ffmpeg-v39.8.2-win32-arm64.zip +5418269cf6fe82f3d9fc5cbbc6d6f9241462b40046a87178515a36cb45549be1 *ffmpeg-v39.8.2-win32-ia32.zip +10bbd25b3e9af36f26147410b31e6e1d928bf4c25ac28571fa1bbe4eb7fe9af9 *ffmpeg-v39.8.2-win32-x64.zip +122ba5515c3a94b272886d156f0bb174ca120a18be44e21bc8b5f586dd679b6e *hunspell_dictionaries.zip +e8aebe7d361983ce1329598f5541c4dde26d18e72228d0ab1ac526c0e1a40dfc *libcxx-objects-v39.8.2-linux-arm64.zip +0d9e646e77ed3fad4560d10f7964cf316cbdcd9a50114c9163427be2222eb35b *libcxx-objects-v39.8.2-linux-armv7l.zip +928c6ff0761f496deda96203960d1933cae1ff488483ea31283a0e8ffe36426a *libcxx-objects-v39.8.2-linux-x64.zip +c65cf035770b74a8a6be4692be704c286427e63eb577e9d10c226b600f6121cc *libcxx_headers.zip +006ccb83761a3bf5791d165bc9795e4de3308c65df646b4cbcaa61025fe7a6c5 *libcxxabi_headers.zip +b189f37011a77ce5d3b6478474172b4594fee626daa75b63da8feb9d376ad983 *mksnapshot-v39.8.2-darwin-arm64.zip +436daa4ae7ca171c51d265976ddc5a5e8ede5b7c1c9cb5467547f14cef87b0c9 *mksnapshot-v39.8.2-darwin-x64.zip +b25ee4873f0bdb9ad663446f9443aa23faeb9e4e2f2734afe47c383e66b6939b *mksnapshot-v39.8.2-linux-arm64-x64.zip +26ebf5acbec96fd08d58d3d9351c26b8cb1ded51a948e0b0513a636deaf17648 *mksnapshot-v39.8.2-linux-armv7l-x64.zip +619b5349abd00d4b7c91894114b2c2aae94d2467d912f476ebcd8c718031493b *mksnapshot-v39.8.2-linux-x64.zip +ec14eeccd924c97a2716b2de5c8279dc5ecb0588c3a333be1dfe4122d192bebc *mksnapshot-v39.8.2-mas-arm64.zip +503f0b1263ebd86c094140307c02c7da474c219b839079a59fcdb1dc1451986a *mksnapshot-v39.8.2-mas-x64.zip +5d7082a1811e11807f78ce9888b00085db92c3dd721d67f54954fd0192570826 *mksnapshot-v39.8.2-win32-arm64-x64.zip +531ea5cd112438eb9276c6327487f8b1f0845b11c080697c438406968d51a859 *mksnapshot-v39.8.2-win32-ia32.zip +5f3ab3f4c4bb7783cd59235a2fffd22ceb86afdafcecdc9492b595302e03ed3e *mksnapshot-v39.8.2-win32-x64.zip diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 0f81323c98d..c393e8247f1 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -122,6 +122,7 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { definitions['ProxyExeBasename'] = embedded.nameShort; definitions['ProxyAppUserId'] = embedded.win32AppUserModelId; definitions['ProxyNameLong'] = embedded.nameLong; + definitions['ProxyExeUrlProtocol'] = embedded.urlProtocol; } if (quality === 'stable' || quality === 'insider') { diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 76c1462e389..ade53b78639 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -668,6 +668,10 @@ "name": "vs/sessions/contrib/logs", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/remoteAgentHost", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/sessions", "project": "vscode-sessions" diff --git a/build/next/index.ts b/build/next/index.ts index 77388b57a26..565bafc72ec 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -129,6 +129,7 @@ const serverEntryPoints = [ 'vs/workbench/api/node/extensionHostProcess', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', + 'vs/platform/agentHost/node/agentHostMain', ]; // Bootstrap files per target diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index f52c0a4696d..0b3d9898015 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -87,7 +87,7 @@ function hashContent(content: string): string { return hash.digest('hex'); } -export function computeState(): PostinstallState { +export function computeState(options?: { ignoreNodeVersion?: boolean }): PostinstallState { const fileHashes: Record = {}; for (const filePath of collectInputFiles()) { const key = path.relative(root, filePath); @@ -97,7 +97,7 @@ export function computeState(): PostinstallState { // file may not be readable } } - return { nodeVersion: process.versions.node, fileHashes }; + return { nodeVersion: options?.ignoreNodeVersion ? '' : process.versions.node, fileHashes }; } export function computeContents(): Record { @@ -141,18 +141,23 @@ export function readSavedContents(): Record | undefined { // When run directly, output state as JSON for tooling (e.g. the vscode-extras extension). if (import.meta.filename === process.argv[1]) { - if (process.argv[2] === '--normalize-file') { - const filePath = process.argv[3]; + const args = new Set(process.argv.slice(2)); + + if (args.has('--normalize-file')) { + const filePath = process.argv[process.argv.indexOf('--normalize-file') + 1]; if (!filePath) { process.exit(1); } process.stdout.write(normalizeFileContent(filePath)); } else { + const ignoreNodeVersion = args.has('--ignore-node-version'); + const current = computeState({ ignoreNodeVersion }); + const saved = readSavedState(); console.log(JSON.stringify({ root, stateContentsFile, - current: computeState(), - saved: readSavedState(), + current, + saved: saved && ignoreNodeVersion ? { nodeVersion: '', fileHashes: saved.fileHashes } : saved, files: [...collectInputFiles(), stateFile], })); } diff --git a/build/package-lock.json b/build/package-lock.json index d2846d70878..cc1acf90b97 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -3510,9 +3510,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", - "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", "dev": true, "funding": [ { @@ -3524,7 +3524,7 @@ "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", - "strnum": "^2.1.2" + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6165,9 +6165,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", "dev": true, "funding": [ { diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index faa4cce0c45..8d0937b8faf 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -2117,9 +2117,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/build/win32/code.iss b/build/win32/code.iss index a61eef9c066..53016d814ae 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1294,6 +1294,15 @@ Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu +; URL Protocol handler for proxy executable +#ifdef ProxyExeBasename +#ifdef ProxyExeUrlProtocol +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: ""; ValueData: "URL:{#ProxyExeUrlProtocol}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#ProxyExeBasename}.exe"" --open-url -- ""%1"""; Flags: uninsdeletekey +#endif +#endif + ; Environment #if "user" == InstallTarget #define EnvironmentRootKey "HKCU" diff --git a/cgmanifest.json b/cgmanifest.json index 281bfb40dc8..2502a1b1ed7 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "e6928c13198c854aa014c319d72eea599e2e0ee7", - "tag": "39.8.3" + "commitHash": "8e0f534873e9fdba5b365879bbdf6b47a0a64e1d", + "tag": "39.8.2" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.8.3" + "version": "39.8.2" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index afe353213b1..e50f85de23a 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -2865,9 +2865,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 423224e10c5..6f54ec61cbb 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -53,7 +53,7 @@ cfg-if = "1.0.0" pin-project = "1.1.0" console = "0.15.7" bytes = "1.11.1" -tar = "0.4.38" +tar = "0.4.45" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index b5330e4df76..955e13f7c68 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -21,7 +21,7 @@ use crate::constants::VSCODE_CLI_QUALITY; use crate::download_cache::DownloadCache; use crate::log; use crate::options::Quality; -use crate::tunnels::paths::SERVER_FOLDER_NAME; +use crate::tunnels::paths::{get_server_folder_name, SERVER_FOLDER_NAME}; use crate::tunnels::shutdown_signal::ShutdownRequest; use crate::update_service::{ unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, @@ -74,8 +74,13 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< // Eagerly resolve the latest version so the first connection is fast. // Skip when using a dev override since updates don't apply. if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_none() { - if let Err(e) = manager.get_latest_release().await { - warning!(ctx.log, "Error resolving initial server version: {}", e); + match manager.get_latest_release().await { + Ok(release) => { + if let Err(e) = manager.ensure_downloaded(&release).await { + warning!(ctx.log, "Error downloading latest server version: {}", e); + } + } + Err(e) => warning!(ctx.log, "Error resolving initial server version: {}", e), } // Start background update checker @@ -253,9 +258,12 @@ impl AgentHostManager { cmd.stdin(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); + cmd.arg("--socket-path"); + cmd.arg(get_socket_name()); cmd.arg("--agent-host-path"); cmd.arg(&agent_host_socket); cmd.args([ + "--start-server", "--accept-server-license-terms", "--enable-remote-auto-shutdown", ]); @@ -394,7 +402,8 @@ impl AgentHostManager { // Best case: the latest known release is already downloaded if let Some((_, release)) = &*self.latest_release.lock().await { - if let Some(dir) = self.cache.exists(&release.commit) { + let name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&name) { return Ok((release.clone(), dir)); } } @@ -405,15 +414,23 @@ impl AgentHostManager { Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) })?; - // Fall back to any cached version (still instant, just not the newest) - for commit in self.cache.get() { - if let Some(dir) = self.cache.exists(&commit) { + // Fall back to any cached version (still instant, just not the newest). + // Cache entries are named "-" via get_server_folder_name. + for entry in self.cache.get() { + if let Some(dir) = self.cache.exists(&entry) { + let (entry_quality, commit) = match entry.split_once('-') { + Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) { + Ok(parsed) => (parsed, c.to_string()), + Err(_) => (quality, entry.clone()), + }, + None => (quality, entry.clone()), + }; let release = Release { name: String::new(), commit, platform: self.platform, target: TargetKind::Server, - quality, + quality: entry_quality, }; return Ok((release, dir)); } @@ -428,7 +445,8 @@ impl AgentHostManager { /// Ensures the release is downloaded, returning the server directory. async fn ensure_downloaded(&self, release: &Release) -> Result { - if let Some(dir) = self.cache.exists(&release.commit) { + let cache_name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&cache_name) { return Ok(dir); } @@ -436,9 +454,8 @@ impl AgentHostManager { let release = release.clone(); let log = self.log.clone(); let update_service = self.update_service.clone(); - let commit = release.commit.clone(); self.cache - .create(&commit, |target_dir| async move { + .create(&cache_name, |target_dir| async move { let tmpdir = tempfile::tempdir().unwrap(); let response = update_service.get_download_stream(&release).await?; let name = response.url_path_basename().unwrap(); @@ -449,7 +466,8 @@ impl AgentHostManager { response, ) .await?; - unzip_downloaded_release(&archive_path, &target_dir, SilentCopyProgress())?; + let server_dir = target_dir.join(SERVER_FOLDER_NAME); + unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?; Ok(()) }) .await @@ -504,7 +522,8 @@ impl AgentHostManager { }; // Check if we already have this version - if self.cache.exists(&new_release.commit).is_some() { + let name = get_server_folder_name(new_release.quality, &new_release.commit); + if self.cache.exists(&name).is_some() { continue; } @@ -562,7 +581,10 @@ async fn handle_request( let rw = match get_socket_rw_stream(&socket_path).await { Ok(rw) => rw, Err(e) => { - error!(manager.log, "Error connecting to agent host socket: {:?}", e); + error!( + manager.log, + "Error connecting to agent host socket: {:?}", e + ); return Ok(Response::builder() .status(503) .body(Body::from(format!("Error connecting to agent host: {e:?}"))) diff --git a/eslint.config.js b/eslint.config.js index ff390f5c4b2..187dcd85864 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -92,6 +92,7 @@ export default tseslint.config( 'local/code-no-localized-model-description': 'warn', 'local/code-policy-localization-key-match': 'warn', 'local/code-no-localization-template-literals': 'error', + 'local/code-no-icons-in-localized-strings': 'warn', 'local/code-no-http-import': ['warn', { target: 'src/vs/**' }], 'local/code-no-deep-import-of-internal': ['error', { '.*Internal': true, 'searchExtTypesInternal': false }], 'local/code-layering': [ @@ -183,6 +184,18 @@ export default tseslint.config( ] } }, + // Disallow common telemetry properties in event data + { + files: [ + 'src/**/*.ts', + ], + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-telemetry-common-property': 'warn', + } + }, // Disallow 'in' operator except in type predicates { files: [ @@ -2373,6 +2386,10 @@ export default tseslint.config( 'selector': `NewExpression[callee.object.name='Intl']`, 'message': 'Use safeIntl helper instead for safe and lazy use of potentially expensive Intl methods.' }, + { + 'selector': 'TSAsExpression[typeAnnotation.type="TSTypeReference"][typeAnnotation.typeName.type="TSQualifiedName"][typeAnnotation.typeName.left.type="Identifier"][typeAnnotation.typeName.left.name="sinon"][typeAnnotation.typeName.right.name="SinonStub"]', + 'message': `Avoid casting with 'as sinon.SinonStub'. Prefer typed stubs from 'sinon.stub(...)' or capture the stub in a typed variable.` + }, ], } }); diff --git a/extensions/git/src/diagnostics.ts b/extensions/git/src/diagnostics.ts index a8c1a3deea3..64bf11076fe 100644 --- a/extensions/git/src/diagnostics.ts +++ b/extensions/git/src/diagnostics.ts @@ -85,7 +85,11 @@ export class GitCommitInputBoxDiagnosticsManager { const threshold = index === 0 ? inputValidationSubjectLength ?? inputValidationLength : inputValidationLength; if (line.text.length > threshold) { - const diagnostic = new Diagnostic(line.range, l10n.t('{0} characters over {1} in current line', line.text.length - threshold, threshold), this.severity); + const charactersOver = line.text.length - threshold; + const lineLengthMessage = charactersOver === 1 + ? l10n.t('{0} character over {1} in current line', charactersOver, threshold) + : l10n.t('{0} characters over {1} in current line', charactersOver, threshold); + const diagnostic = new Diagnostic(line.range, lineLengthMessage, this.severity); diagnostic.code = DiagnosticCodes.line_length; diagnostics.push(diagnostic); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 687dffa25f4..a20a8b0002f 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -465,9 +465,9 @@ class DotGitWatcher implements IFileWatcher { const rootWatcher = watch(repository.dotGit.path); this.disposables.push(rootWatcher); - // Ignore changes to the "index.lock" file, and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. + // Ignore changes to the "index.lock" file (including worktree index.lock files), and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. // Watchman creates a cookie file inside the git directory whenever a query is run (https://facebook.github.io/watchman/docs/cookies.html). - const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); + const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock|\/worktrees\/[^/]+\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); this.event = anyEvent(filteredRootWatcher, this.emitter.event); repository.onDidRunGitStatus(this.updateTransientWatchers, this, this.disposables); @@ -932,7 +932,7 @@ export class Repository implements Disposable { // FS changes should trigger `git status`: // - any change inside the repository working tree - // - any change whithin the first level of the `.git` folder, except the folder itself and `index.lock` + // - any change within the first level of the `.git` folder, except the folder itself and `index.lock` (repository and worktree) const onFileChange = anyEvent(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange); onFileChange(this.onFileChange, this, this.disposables); diff --git a/extensions/grunt/src/main.ts b/extensions/grunt/src/main.ts index b94b00c4462..9c2fee82b50 100644 --- a/extensions/grunt/src/main.ts +++ b/extensions/grunt/src/main.ts @@ -18,9 +18,9 @@ function exists(file: string): Promise { }); } -function exec(command: string, options: cp.ExecOptions): Promise<{ stdout: string; stderr: string }> { +function exec(command: string, args: string[], options: cp.ExecFileOptions): Promise<{ stdout: string; stderr: string }> { return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - cp.exec(command, options, (error, stdout, stderr) => { + cp.execFile(command, args, options, (error, stdout, stderr) => { if (error) { reject({ error, stdout, stderr }); } @@ -143,9 +143,9 @@ class FolderDetector { return emptyTasks; } - const commandLine = `${await this._gruntCommand} --help --no-color`; + const gruntCommand = await this._gruntCommand; try { - const { stdout, stderr } = await exec(commandLine, { cwd: rootPath }); + const { stdout, stderr } = await exec(gruntCommand, ['--help', '--no-color'], { cwd: rootPath }); if (stderr) { getOutputChannel().appendLine(stderr); showError(); diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 59a0c8677fe..bf4dfcb250a 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -42,6 +42,14 @@ "category": "Simple Browser" } ], + "menus": { + "commandPalette": [ + { + "command": "simpleBrowser.show", + "when": "isWeb" + } + ] + }, "configuration": [ { "title": "Simple Browser", @@ -51,12 +59,6 @@ "default": true, "title": "Focus Lock Indicator Enabled", "description": "%configuration.focusLockIndicator.enabled.description%" - }, - "simpleBrowser.useIntegratedBrowser": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.useIntegratedBrowser.description%", - "scope": "application" } } } diff --git a/extensions/simple-browser/package.nls.json b/extensions/simple-browser/package.nls.json index 0b88b068fbc..496dc28dfdd 100644 --- a/extensions/simple-browser/package.nls.json +++ b/extensions/simple-browser/package.nls.json @@ -1,6 +1,5 @@ { "displayName": "Simple Browser", "description": "A very basic built-in webview for displaying web content.", - "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser.", - "configuration.useIntegratedBrowser.description": "When enabled, the `simpleBrowser.show` command will open URLs in the integrated browser instead of the Simple Browser webview. **Note:** This setting is only available on desktop." + "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser." } diff --git a/extensions/simple-browser/src/extension.ts b/extensions/simple-browser/src/extension.ts index 75ee87d4da7..ddcdc52b42d 100644 --- a/extensions/simple-browser/src/extension.ts +++ b/extensions/simple-browser/src/extension.ts @@ -15,7 +15,6 @@ declare class URL { const openApiCommand = 'simpleBrowser.api.open'; const showCommand = 'simpleBrowser.show'; const integratedBrowserCommand = 'workbench.action.browser.open'; -const useIntegratedBrowserSetting = 'simpleBrowser.useIntegratedBrowser'; const enabledHosts = new Set([ 'localhost', @@ -37,12 +36,6 @@ const openerId = 'simpleBrowser.open'; * Checks if the integrated browser should be used instead of the simple browser */ async function shouldUseIntegratedBrowser(): Promise { - const config = vscode.workspace.getConfiguration(); - if (!config.get(useIntegratedBrowserSetting, true)) { - return false; - } - - // Verify that the integrated browser command is available const commands = await vscode.commands.getCommands(true); return commands.includes(integratedBrowserCommand); } diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index fc0bcbb66bf..0791391e6af 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { window, ViewColumn } from 'vscode'; +import { window, commands, ViewColumn } from 'vscode'; import { assertNoRpc, closeAllEditors } from '../utils'; (vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('vscode API - browser', () => { @@ -73,6 +73,16 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.strictEqual(window.browserTabs.length, countBefore - 1); }); + test('Can move a browser tab to a new group and close it successfully', async () => { + const tab = await window.openBrowserTab('about:blank'); + assert.ok(window.browserTabs.includes(tab)); + + await commands.executeCommand('workbench.action.moveEditorToNextGroup'); + + await tab.close(); + assert.ok(!window.browserTabs.includes(tab)); + }); + // #endregion // #region onDidOpenBrowserTab diff --git a/package-lock.json b/package-lock.json index 777c60561a8..7ef401451f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -22,7 +22,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -106,7 +106,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.3", + "electron": "39.8.2", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -419,15 +419,15 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.23.tgz", - "integrity": "sha512-Np0VRH6D71cGoJZvd8hCz1LMfwg9ERJovrOJSCz5aSQSQJPWPNIFPV1wfc8oAhJpStOuYkot+EmXOkRRxuGMCQ==", + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", + "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -4779,9 +4779,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.39.1.tgz", - "integrity": "sha512-Au6ra1oVBNlxgroyr58VuaO2mVH02xidPfN6o0znYr/NQ8OvXNm4CVM44iwyCP1sOzhLKjMoPEeWIbKDpLgtFg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.40.0.tgz", + "integrity": "sha512-G2OUy5b2vxYXoRWo38BwxBKW1GCjwno9tivcshJNBWkeHjwcidLkL6KFaVRgIDDxJjojPkoxy9AivTDU/ksJ6g==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", @@ -8640,9 +8640,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.8.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.3.tgz", - "integrity": "sha512-ZhetvWz2qbI2WbBHdK/utR8I5bi1pYWJdit9tP0sGzs42CpsAFyu/FirXE88NWSh+3U8X6Wuf9jjDEYvAyrxNw==", + "version": "39.8.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.2.tgz", + "integrity": "sha512-uwNJHeqm8pzQEZf/KX4XM1fJctZpHcA0Za/MlP9mOg0FAWHbKo6yRC33QbdfLX7PeNjYZC3I3nnVhE5L2CLqxw==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index fb4e1fdd1eb..212143c69c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.113.0", - "distro": "f7f14fdd95367f272a8a1fc24811b3b55bdd0fe3", + "distro": "6b93ffda14819d043903eeed60601e289e01b8f6", "author": { "name": "Microsoft Corporation" }, @@ -80,7 +80,7 @@ "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/vite && npm install @vscode/component-explorer-vite-plugin@next && npm install @vscode/component-explorer@next" }, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -92,7 +92,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -176,7 +176,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.3", + "electron": "39.8.2", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -249,4 +249,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} diff --git a/remote/.npmrc b/remote/.npmrc index 7c6849a8708..8310ec94634 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -1,6 +1,6 @@ disturl="https://nodejs.org/dist" target="22.22.1" -ms_build_id="420922" +ms_build_id="420065" runtime="node" build_from_source="true" legacy-peer-deps="true" diff --git a/remote/package-lock.json b/remote/package-lock.json index ee126bb8a8e..e58902bd620 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,7 +8,7 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -17,7 +17,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", @@ -52,15 +52,15 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.23.tgz", - "integrity": "sha512-Np0VRH6D71cGoJZvd8hCz1LMfwg9ERJovrOJSCz5aSQSQJPWPNIFPV1wfc8oAhJpStOuYkot+EmXOkRRxuGMCQ==", + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", + "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -608,9 +608,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.39.1.tgz", - "integrity": "sha512-Au6ra1oVBNlxgroyr58VuaO2mVH02xidPfN6o0znYr/NQ8OvXNm4CVM44iwyCP1sOzhLKjMoPEeWIbKDpLgtFg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.40.0.tgz", + "integrity": "sha512-G2OUy5b2vxYXoRWo38BwxBKW1GCjwno9tivcshJNBWkeHjwcidLkL6KFaVRgIDDxJjojPkoxy9AivTDU/ksJ6g==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index 0142cf0cb92..0fbecc3c5a4 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -12,7 +12,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", diff --git a/src/vs/base/browser/ui/hover/hoverWidget.css b/src/vs/base/browser/ui/hover/hoverWidget.css index 85379221cf2..c2837659d5f 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.css +++ b/src/vs/base/browser/ui/hover/hoverWidget.css @@ -139,6 +139,7 @@ .monaco-hover .hover-row.status-bar .actions .action-container .action .icon { padding-right: 4px; vertical-align: middle; + font-size: inherit; } .monaco-hover .hover-row.status-bar .actions .action-container a { diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 6c604269ac5..9d6d200f68f 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDragAndDropData } from '../../dnd.js'; -import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate } from '../list/list.js'; +import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate, NotSelectableGroupIdType } from '../list/list.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../list/listView.js'; import { IListStyles } from '../list/listWidget.js'; import { ComposedTreeDelegate, TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent, IFindControllerOptions, IStickyScrollDelegate, AbstractTree } from './abstractTree.js'; @@ -1309,7 +1309,10 @@ export class AsyncDataTree implements IDisposable diffIdentityProvider: options.diffIdentityProvider && { getId(node: IAsyncDataTreeNode): { toString(): string } { return options.diffIdentityProvider!.getId(node.element as T); - } + }, + getGroupId: options.diffIdentityProvider!.getGroupId ? (node: IAsyncDataTreeNode): number | NotSelectableGroupIdType => { + return options.diffIdentityProvider!.getGroupId!(node.element as T); + } : undefined } }; diff --git a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts index e4adc832676..0bcaa01c426 100644 --- a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts +++ b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IIdentityProvider } from '../list/list.js'; +import { IIdentityProvider, NotSelectableGroupIdType } from '../list/list.js'; import { getVisibleState, IIndexTreeModelSpliceOptions, isFilterResult } from './indexTreeModel.js'; import { IObjectTreeModel, IObjectTreeModelOptions, IObjectTreeModelSetChildrenOptions, ObjectTreeModel } from './objectTreeModel.js'; import { ICollapseStateChangeEvent, IObjectTreeElement, ITreeListSpliceData, ITreeModel, ITreeModelSpliceEvent, ITreeNode, TreeError, TreeFilterResult, TreeVisibility, WeakMapper } from './tree.js'; @@ -113,7 +113,10 @@ interface ICompressedObjectTreeModelOptions extends IObjectTreeM const wrapIdentityProvider = (base: IIdentityProvider): IIdentityProvider> => ({ getId(node) { return node.elements.map(e => base.getId(e).toString()).join('\0'); - } + }, + getGroupId: base.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return base.getGroupId!(node.elements[node.elements.length - 1]); + } : undefined }); // Exported only for test reasons, do not use directly @@ -380,7 +383,10 @@ function mapOptions(compressedNodeUnwrapper: CompressedNodeUnwra identityProvider: options.identityProvider && { getId(node: ICompressedTreeNode): { toString(): string } { return options.identityProvider!.getId(compressedNodeUnwrapper(node)); - } + }, + getGroupId: options.identityProvider!.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return options.identityProvider!.getGroupId!(compressedNodeUnwrapper(node)); + } : undefined }, sorter: options.sorter && { compare(node: ICompressedTreeNode, otherNode: ICompressedTreeNode): number { diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index ab8644403c0..929ed2f9e03 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -1029,7 +1029,8 @@ class LeakageMonitor { console.warn(message); console.warn(topStack); - const error = new ListenerLeakError(message, topStack); + const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerLeakError(kind, message, topStack); this._errorHandler(error); } @@ -1077,8 +1078,8 @@ export class ListenerLeakError extends Error { * `message` so that all leak errors group under the same title in telemetry. */ readonly details: string; - constructor(details: string, stack: string) { - super('potential listener LEAK detected'); + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind}`); this.name = 'ListenerLeakError'; this.details = details; this.stack = stack; @@ -1091,11 +1092,11 @@ export class ListenerRefusalError extends Error { /** * The detailed message including listener count and most frequent stack. * Available locally for debugging but intentionally not used as the error - * `message` so that all refusal errors group under the same title in telemetry. + * `message` so that all leak errors group under the same title in telemetry. */ readonly details: string; - constructor(details: string, stack: string) { - super('potential listener LEAK detected (REFUSED to add)'); + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind} (REFUSED to add)`); this.name = 'ListenerRefusalError'; this.details = details; this.stack = stack; @@ -1235,7 +1236,8 @@ export class Emitter { console.warn(message); const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1]; - const error = new ListenerRefusalError(`${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); + const kind = tuple[1] / this._size > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerRefusalError(kind, `${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); const errorHandler = this._options?.onListenerError || onUnexpectedError; errorHandler(error); diff --git a/src/vs/base/common/resources.ts b/src/vs/base/common/resources.ts index 3b8370c160b..a064a287736 100644 --- a/src/vs/base/common/resources.ts +++ b/src/vs/base/common/resources.ts @@ -190,8 +190,8 @@ export class ExtUri implements IExtUri { return basename(resource) || resource.authority; } - basename(resource: URI): string { - return paths.posix.basename(resource.path); + basename(resource: URI, suffix?: string): string { + return paths.posix.basename(resource.path, suffix); } extname(resource: URI): string { diff --git a/src/vs/base/test/browser/ui/tree/objectTree.test.ts b/src/vs/base/test/browser/ui/tree/objectTree.test.ts index aa11fbe6036..8902791afce 100644 --- a/src/vs/base/test/browser/ui/tree/objectTree.test.ts +++ b/src/vs/base/test/browser/ui/tree/objectTree.test.ts @@ -8,6 +8,7 @@ import { IIdentityProvider, IListVirtualDelegate } from '../../../../browser/ui/ import { ICompressedTreeNode } from '../../../../browser/ui/tree/compressedObjectTreeModel.js'; import { CompressibleObjectTree, ICompressibleTreeRenderer, ObjectTree } from '../../../../browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeRenderer } from '../../../../browser/ui/tree/tree.js'; +import { runWithFakedTimers } from '../../../common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; function getRowsTextContent(container: HTMLElement): string[] { @@ -16,6 +17,17 @@ function getRowsTextContent(container: HTMLElement): string[] { return rows.map(row => row.querySelector('.monaco-tl-contents')!.textContent!); } +function clickElement(element: HTMLElement, ctrlKey = false): void { + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, ctrlKey, button: 0 })); + element.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey, button: 0 })); +} + +function dispatchKeydown(element: HTMLElement, key: string, code: string, keyCode: number): void { + const keyboardEvent = new KeyboardEvent('keydown', { bubbles: true, key, code }); + Object.defineProperty(keyboardEvent, 'keyCode', { get: () => keyCode }); + element.dispatchEvent(keyboardEvent); +} + suite('ObjectTree', function () { suite('TreeNavigator', function () { @@ -231,6 +243,84 @@ suite('ObjectTree', function () { tree.setChildren(null, [{ element: 100 }, { element: 101 }, { element: 102 }, { element: 103 }]); assert.deepStrictEqual(tree.getFocus(), [101]); }); + + test('updateOptions preserves wrapped identity provider in view options', function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const identityProvider = { + getId(element: number): { toString(): string } { + return `${element}`; + }, + getGroupId(element: number): number { + return element % 2; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { identityProvider }); + + try { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }, { element: 1 }, { element: 2 }, { element: 3 }]); + + const firstRow = container.querySelector('.monaco-list-row[data-index="0"]') as HTMLElement; + const secondRow = container.querySelector('.monaco-list-row[data-index="1"]') as HTMLElement; + clickElement(firstRow); + assert.deepStrictEqual(tree.getSelection(), [0]); + + tree.updateOptions({ indent: 12 }); + + clickElement(secondRow, true); + + assert.deepStrictEqual(tree.getSelection(), [1]); + } finally { + tree.dispose(); + } + }); + + test('updateOptions preserves wrapped accessibility provider for type navigation re-announce', async function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const accessibilityProvider = { + getAriaLabel(element: number): string { + assert.strictEqual(typeof element, 'number'); + return `aria ${element}`; + }, + getWidgetAriaLabel(): string { + return 'tree'; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { + accessibilityProvider, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: () => 'a' + } + }); + + try { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }]); + tree.setFocus([0]); + tree.domFocus(); + + tree.updateOptions({ indent: 12 }); + + dispatchKeydown(tree.getHTMLElement(), 'a', 'KeyA', 65); + await Promise.resolve(); + }); + } finally { + tree.dispose(); + } + }); }); suite('CompressibleObjectTree', function () { diff --git a/src/vs/base/test/common/sinonUtils.ts b/src/vs/base/test/common/sinonUtils.ts new file mode 100644 index 00000000000..ef256b115a0 --- /dev/null +++ b/src/vs/base/test/common/sinonUtils.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; + +export function asSinonMethodStub unknown>(method: T): sinon.SinonStubbedMember { + return method as unknown as sinon.SinonStubbedMember; +} diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index e1d18bf87c5..2f57f7bf1d5 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -743,6 +743,7 @@ export class CodeApplication extends Disposable { const openables: IWindowOpenable[] = []; const urls: IProtocolUrl[] = []; + for (const protocolUrl of protocolUrls) { if (!protocolUrl) { continue; // invalid @@ -750,6 +751,12 @@ export class CodeApplication extends Disposable { const windowOpenable = this.getWindowOpenableFromProtocolUrl(protocolUrl.uri); if (windowOpenable) { + // Sessions app: skip all window openables (file/folder/workspace) + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#resolveInitialProtocolUrls() sessions app skipping window openable:', protocolUrl.uri.toString(true)); + continue; + } + if (await this.shouldBlockOpenable(windowOpenable, windowsMainService, dialogMainService)) { this.logService.trace('app#resolveInitialProtocolUrls() protocol url was blocked:', protocolUrl.uri.toString(true)); @@ -895,13 +902,24 @@ export class CodeApplication extends Disposable { private async handleProtocolUrl(windowsMainService: IWindowsMainService, dialogMainService: IDialogMainService, urlService: IURLService, uri: URI, options?: IOpenURLOptions): Promise { this.logService.trace('app#handleProtocolUrl():', uri.toString(true), options); - // Sessions app: "open a sessions window", regardless of other parameters. + // Sessions app: ensure the sessions window is open, then let other handlers process the URL. if ((process as INodeProcess).isEmbeddedApp) { - this.logService.trace('app#handleProtocolUrl() opening sessions window for bare protocol URL:', uri.toString(true)); + this.logService.trace('app#handleProtocolUrl() sessions app handling protocol URL:', uri.toString(true)); - await windowsMainService.openSessionsWindow({ context: OpenContext.LINK, contextWindowId: undefined }); + // Skip window openables (file/folder/workspace) for security + const windowOpenable = this.getWindowOpenableFromProtocolUrl(uri); + if (windowOpenable) { + this.logService.trace('app#handleProtocolUrl() sessions app skipping window openable:', uri.toString(true)); + return true; + } - return true; + // Ensure sessions window is open to receive the URL + const windows = await windowsMainService.openSessionsWindow({ context: OpenContext.LINK, contextWindowId: undefined }); + const window = windows.at(0); + await window?.ready(); + + // Return false to let subsequent handlers (e.g., URLHandlerChannelClient) forward the URL + return false; } // Support 'workspace' URLs (https://github.com/microsoft/vscode/issues/124263) diff --git a/src/vs/editor/browser/view/domLineBreaksComputer.ts b/src/vs/editor/browser/view/domLineBreaksComputer.ts index ba4de747797..1c88653206f 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -307,7 +307,7 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: try { discoverBreaks(range, spans, charOffsets, 0, null, lineContent.length - 1, null, breakOffsets); } catch (err) { - console.log(err); + console.error(err); return null; } diff --git a/src/vs/editor/common/core/ranges/offsetRange.ts b/src/vs/editor/common/core/ranges/offsetRange.ts index e279b382078..72d116282e2 100644 --- a/src/vs/editor/common/core/ranges/offsetRange.ts +++ b/src/vs/editor/common/core/ranges/offsetRange.ts @@ -257,7 +257,7 @@ export class OffsetRangeSet { } /** - * Returns of there is a value that is contained in this instance and the given range. + * Returns if there is a value that is contained in this instance and the given range. */ public intersectsStrict(other: OffsetRange): boolean { // TODO use binary search diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index c9f7c4479de..4964af49280 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -22,6 +22,8 @@ import { CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSour import { MarkerController, NextMarkerAction } from '../../gotoError/browser/gotoError.js'; import { HoverAnchor, HoverAnchorType, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart, IRenderedHoverPart, IRenderedHoverParts, RenderedHoverParts } from './hoverTypes.js'; import * as nls from '../../../../nls.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IMarker, IMarkerData, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -65,6 +67,8 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant { + for (const action of menuActions) { + context.statusBar.addAction({ + label: action.label, + commandId: action.id, + iconClass: action.class, + run: () => { + context.hide(); + this._editor.setSelection(Range.lift(markerHover.range)); + action.run(); + } + }); + } + }; + if (!this._editor.getOption(EditorOption.readOnly)) { const quickfixPlaceholderElement = context.statusBar.append($('div')); if (this.recentMarkerCodeActionsInfo) { if (IMarkerData.makeKey(this.recentMarkerCodeActionsInfo.marker) === IMarkerData.makeKey(markerHover.marker)) { if (!this.recentMarkerCodeActionsInfo.hasCodeActions) { - quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + if (menuActions.length === 0) { + quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + } } } else { this.recentMarkerCodeActionsInfo = undefined; @@ -230,7 +260,12 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant implements IListRenderer, IAction private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, private readonly _onSubmenuIndicatorHover: ((element: IActionListItem, indicator: HTMLElement, disposables: DisposableStore) => void) | undefined, private _hasAnySubmenuActions: boolean, + private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -284,7 +285,12 @@ class ActionItemRenderer implements IListRenderer, IAction } else { const rendered = renderMarkdown(element.description, { actionHandler: (content: string) => { - this._openerService.open(URI.parse(content), { allowCommands: true }); + const uri = URI.parse(content); + if (this._linkHandler) { + this._linkHandler(uri, element); + } else { + void this._openerService.open(uri, { allowCommands: true }); + } } }); data.elementDisposables.add(rendered); @@ -404,6 +410,17 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * Optional handler for markdown links activated in item descriptions or hovers. + * When unset, links open via the opener service with command links allowed. + */ + readonly linkHandler?: (uri: URI, item: IActionListItem) => void; + + /** + * Optional callback fired when a section's collapsed state changes. + */ + readonly onDidToggleSection?: (section: string, collapsed: boolean) => void; + /** * When true, descriptions are rendered as subtext below the title * instead of inline to the right. @@ -518,7 +535,7 @@ export class ActionListWidget extends Disposable { const hasAnySubmenuActions = items.some(item => !!item.submenuActions?.length); this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer(preview, (item) => this._removeItem(item), (element, indicator, disposables) => this._wireSubmenuIndicator(element, indicator, disposables), hasAnySubmenuActions, this._keybindingService, this._openerService), + new ActionItemRenderer(preview, (item) => this._removeItem(item), (element, indicator, disposables) => this._wireSubmenuIndicator(element, indicator, disposables), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -638,6 +655,7 @@ export class ActionListWidget extends Disposable { } else { this._collapsedSections.add(section); } + this._options?.onDidToggleSection?.(section, this._collapsedSections.has(section)); this._applyFilter(); } @@ -1162,10 +1180,14 @@ export class ActionListWidget extends Disposable { } const markdown = typeof element.hover!.content === 'string' ? new MarkdownString(element.hover!.content) : element.hover!.content; + const linkHandler = this._options?.linkHandler; this._hover.value = this._hoverService.showDelayedHover({ content: markdown ?? '', target: rowElement, additionalClasses: ['action-widget-hover'], + linkHandler: linkHandler ? (url: string) => { + linkHandler(URI.parse(url), element); + } : undefined, position: { hoverPosition: HoverPosition.LEFT, forcePosition: false, diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 9fff2b44ce0..539c9cfe1df 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -309,6 +309,7 @@ export class MenuId { static readonly ChatViewSessionTitleNavigationToolbar = new MenuId('ChatViewSessionTitleNavigationToolbar'); static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar'); static readonly ChatContextUsageActions = new MenuId('ChatContextUsageActions'); + static readonly MarkerHoverStatusBar = new MenuId('MarkerHoverParticipant.StatusBar'); /** * Create or reuse a `MenuId` with the given identifier diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 63c2c6a93e4..94b2c6cb56d 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; @@ -31,6 +32,7 @@ export interface IAgentSessionMetadata { readonly startTime: number; readonly modifiedTime: number; readonly summary?: string; + readonly workingDirectory?: string; } export type AgentProvider = string; @@ -40,10 +42,55 @@ export interface IAgentDescriptor { readonly provider: AgentProvider; readonly displayName: string; readonly description: string; - /** Whether the renderer should push a GitHub auth token for this agent. */ + /** + * Whether the renderer should push a GitHub auth token for this agent. + * @deprecated Use {@link IResourceMetadata.resources} from {@link IAgentService.getResourceMetadata} instead. + */ readonly requiresAuth: boolean; } +// ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Uses {@link IAuthorizationProtectedResourceMetadata} from RFC 9728 + * to describe auth requirements, enabling clients to resolve tokens + * using the standard VS Code authentication service. + * + * Returned from the server via {@link IAgentService.getResourceMetadata}. + */ +export interface IResourceMetadata { + /** + * Protected resources the agent host requires authentication for. + * Each entry uses the standard RFC 9728 shape so clients can resolve + * tokens via {@link IAuthenticationService.getOrActivateProviderIdForServer}. + */ + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 section 2.1). + */ +export interface IAuthenticateParams { + /** + * The `resource` identifier from the server's + * {@link IAuthorizationProtectedResourceMetadata} that this token targets. + */ + readonly resource: string; + + /** The bearer token value (RFC 6750). */ + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +export interface IAuthenticateResult { + /** Whether the token was accepted. */ + readonly authenticated: boolean; +} + export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: string; @@ -301,8 +348,14 @@ export interface IAgent { /** List persisted sessions from this provider. */ listSessions(): Promise; - /** Set the authentication token for this provider. */ - setAuthToken(token: string): Promise; + /** Declare protected resources this agent requires auth for (RFC 9728). */ + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + + /** + * Authenticate for a specific resource. Returns true if accepted. + * The `resource` matches {@link IAuthorizationProtectedResourceMetadata.resource}. + */ + authenticate(resource: string, token: string): Promise; /** Gracefully shut down all sessions. */ shutdown(): Promise; @@ -329,8 +382,18 @@ export interface IAgentService { /** Discover available agent backends from the agent host. */ listAgents(): Promise; - /** Set the GitHub auth token used by the Copilot SDK. */ - setAuthToken(token: string): Promise; + /** + * Retrieve the resource metadata describing auth requirements. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ + getResourceMetadata(): Promise; + + /** + * Authenticate for a protected resource on the server. + * The {@link IAuthenticateParams.resource} must match a resource from + * {@link getResourceMetadata}. Analogous to RFC 6750 bearer token delivery. + */ + authenticate(params: IAuthenticateParams): Promise; /** * Refresh the model list from all providers, publishing updated 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 8dfc004dcbd..4c38cb047de 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 @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 40f4b2734f4..3b5eb0b636e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ToolCallConfirmationReason, ToolCallCancellationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type IPermissionRequest } from './state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 676841e0728..34c445623f7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; @@ -172,7 +172,7 @@ export interface ICreateSessionParams { /** Model ID to use */ model?: string; /** Working directory for the session */ - workingDirectory?: string; + workingDirectory?: URI; } // ─── disposeSession ────────────────────────────────────────────────────────── @@ -255,25 +255,31 @@ export const enum ContentEncoding { * { "jsonrpc": "2.0", "id": 10, "result": { * "data": "iVBORw0KGgo...", * "encoding": "base64", - * "mimeType": "image/png" + * "contentType": "image/png" * }} * ``` */ export interface IFetchContentParams { /** Content URI from a `ContentRef` */ uri: string; + /** Preferred encoding for the returned data (default: server-chosen) */ + encoding?: ContentEncoding; } /** * Result of the `fetchContent` command. + * + * The server SHOULD honor the `encoding` requested in the params. If the + * server cannot provide the requested encoding, it MUST fall back to either + * `base64` or `utf-8`. */ export interface IFetchContentResult { /** Content encoded as a string */ data: string; /** How `data` is encoded */ encoding: ContentEncoding; - /** MIME type of the content */ - mimeType?: string; + /** Content type (e.g. `"image/png"`, `"text/plain"`) */ + contentType?: string; } // ─── browseDirectory ──────────────────────────────────────────────────────── @@ -427,3 +433,54 @@ export interface IBrowseDirectoryEntry { /** Whether this entry is a directory */ isDirectory: boolean; } + +// ─── authenticate ──────────────────────────────────────────────────────────── + +/** + * Pushes a Bearer token for a protected resource. The `resource` field MUST + * match an `IProtectedResourceMetadata.resource` value declared by an agent + * in `IAgentInfo.protectedResources`. + * + * Tokens are delivered using [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) + * (Bearer Token Usage) semantics. The client obtains the token from the + * authorization server(s) listed in the resource's metadata and pushes it + * to the server via this command. + * + * @category Commands + * @method authenticate + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 3, "method": "authenticate", + * "params": { "resource": "https://api.github.com", "token": "gho_xxxx" } } + * + * // Server → Client (success) + * { "jsonrpc": "2.0", "id": 3, "result": {} } + * + * // Server → Client (failure — invalid token) + * { "jsonrpc": "2.0", "id": 3, "error": { "code": -32007, "message": "Invalid token" } } + * ``` + */ +export interface IAuthenticateParams { + /** + * The protected resource identifier. MUST match a `resource` value from + * `IProtectedResourceMetadata` declared in `IAgentInfo.protectedResources`. + */ + resource: string; + /** Bearer token obtained from the resource's authorization server */ + token: string; +} + +/** + * Result of the `authenticate` command. + * + * An empty object on success. If the token is invalid or the resource is + * unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` + * `-32007` or `InvalidParams` `-32602`). + */ +export interface IAuthenticateResult { +} diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index 638189c2bc1..d8f1d609b78 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── @@ -48,6 +48,15 @@ export const AhpErrorCodes = { UnsupportedProtocolVersion: -32005, /** The requested content URI does not exist */ ContentNotFound: -32006, + /** + * A command failed because the client has not authenticated for a required + * protected resource. The `data` field of the JSON-RPC error SHOULD contain + * an `IProtectedResourceMetadata[]` array describing the resources that + * require authentication. + * + * @see {@link /specification/authentication | Authentication} + */ + AuthRequired: -32007, } as const; /** Union type of all AHP application error codes. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 395da78f6ea..edbb71701d1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -5,9 +5,9 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams } from './commands.js'; +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; import type { IActionEnvelope } from './actions.js'; import type { IProtocolNotification } from './notifications.js'; @@ -67,6 +67,7 @@ export interface ICommandMap { 'fetchContent': { params: IFetchContentParams; result: IFetchContentResult }; 'browseDirectory': { params: IBrowseDirectoryParams; result: IBrowseDirectoryResult }; 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; + 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; } // ─── Notification Maps ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts index 3a55ca3b658..ea497c9127b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -5,10 +5,22 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISessionSummary } from './state.js'; +/** + * Reason why authentication is required. + * + * @category Protocol Notifications + */ +export const enum AuthRequiredReason { + /** The client has not yet authenticated for the resource */ + Required = 'required', + /** A previously valid token has expired or been revoked */ + Expired = 'expired', +} + // ─── Protocol Notifications ────────────────────────────────────────────────── /** @@ -19,6 +31,7 @@ import type { URI, ISessionSummary } from './state.js'; export const enum NotificationType { SessionAdded = 'notify/sessionAdded', SessionRemoved = 'notify/sessionRemoved', + AuthRequired = 'notify/authRequired', } /** @@ -78,9 +91,44 @@ export interface ISessionRemovedNotification { session: URI; } +/** + * Sent by the server when a protected resource requires (re-)authentication. + * + * This notification is sent when a previously valid token expires or is + * revoked, or when the server discovers a new authentication requirement. + * Clients should obtain a fresh token and push it via the `authenticate` + * command. + * + * @category Protocol Notifications + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "notification", + * "params": { + * "notification": { + * "type": "notify/authRequired", + * "resource": "https://api.github.com", + * "reason": "expired" + * } + * } + * } + * ``` + */ +export interface IAuthRequiredNotification { + type: NotificationType.AuthRequired; + /** The protected resource identifier that requires authentication */ + resource: string; + /** Why authentication is required */ + reason?: AuthRequiredReason; +} + /** * Discriminated union of all protocol notifications. */ export type IProtocolNotification = | ISessionAddedNotification - | ISessionRemovedNotification; + | ISessionRemovedNotification + | IAuthRequiredNotification; diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index ce78d37dc40..4aa21b64e8b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ActionType } from './actions.js'; import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, type IRootState, type ISessionState, type IToolCallState, type IToolCallCompletedState, type IToolCallCancelledState, type ITurn } from './state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index a037ca22059..a2d6e1f8a50 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -20,6 +20,72 @@ export type URI = string; */ export type StringOrMarkdown = string | { markdown: string }; +// ─── Protected Resource Metadata (RFC 9728) ───────────────────────────────── + +/** + * Describes a protected resource's authentication requirements using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) (OAuth 2.0 + * Protected Resource Metadata) semantics. + * + * Field names use snake_case to match the RFC 9728 JSON format. + * + * @category Authentication + * @see {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} + */ +export interface IProtectedResourceMetadata { + /** + * REQUIRED. The protected resource's resource identifier, a URL using the + * `https` scheme with no fragment component (e.g. `"https://api.github.com"`). + */ + resource: string; + + /** OPTIONAL. Human-readable name of the protected resource. */ + resource_name?: string; + + /** OPTIONAL. JSON array of OAuth authorization server identifier URLs. */ + authorization_servers?: string[]; + + /** OPTIONAL. URL of the protected resource's JWK Set document. */ + jwks_uri?: string; + + /** RECOMMENDED. JSON array of OAuth 2.0 scope values used in authorization requests. */ + scopes_supported?: string[]; + + /** OPTIONAL. JSON array of Bearer Token presentation methods supported. */ + bearer_methods_supported?: string[]; + + /** OPTIONAL. JSON array of JWS signing algorithms supported. */ + resource_signing_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (alg) supported. */ + resource_encryption_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (enc) supported. */ + resource_encryption_enc_values_supported?: string[]; + + /** OPTIONAL. URL of human-readable documentation for the resource. */ + resource_documentation?: string; + + /** OPTIONAL. URL of the resource's data-usage policy. */ + resource_policy_uri?: string; + + /** OPTIONAL. URL of the resource's terms of service. */ + resource_tos_uri?: string; + + /** + * AHP extension. Whether authentication is required for this resource. + * + * - `true` (default) — the agent cannot be used without a valid token. + * The server SHOULD return `AuthRequired` (`-32007`) if the client + * attempts to use the agent without authenticating. + * - `false` — the agent works without authentication but MAY offer + * enhanced capabilities when a token is provided. + * + * Clients SHOULD treat an absent field the same as `true`. + */ + required?: boolean; +} + // ─── Root State ────────────────────────────────────────────────────────────── /** @@ -57,6 +123,18 @@ export interface IAgentInfo { description: string; /** Available models for this agent */ models: ISessionModelInfo[]; + /** + * Protected resources this agent requires authentication for. + * + * Each entry describes an OAuth 2.0 protected resource using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. + * Clients should obtain tokens from the declared `authorization_servers` + * and push them via the `authenticate` command before creating sessions + * with this agent. + * + * @see {@link /specification/authentication | Authentication} + */ + protectedResources?: IProtectedResourceMetadata[]; } /** @@ -117,6 +195,8 @@ export interface ISessionState { serverTools?: IToolDefinition[]; /** The client currently providing tools and interactive capabilities to this session */ activeClient?: ISessionActiveClient; + /** The working directory URI for this session */ + workingDirectory?: URI; /** Completed turns */ turns: ITurn[]; /** Currently in-progress turn */ @@ -158,6 +238,8 @@ export interface ISessionSummary { modifiedAt: number; /** Currently selected model */ model?: string; + /** The working directory URI for this session */ + workingDirectory?: URI; } // ─── Turn Types ────────────────────────────────────────────────────────────── @@ -578,6 +660,7 @@ export interface IToolAnnotations { export const enum ToolResultContentType { Text = 'text', Binary = 'binary', + FileEdit = 'fileEdit', } /** @@ -608,17 +691,41 @@ export interface IToolResultBinaryContent { contentType: string; } +/** + * Describes a file modification performed by a tool. + * + * Clients can use the `beforeURI`/`afterURI` pair to render a diff view. + * + * @category Tool Result Content + */ +export interface IToolResultFileEditContent { + type: ToolResultContentType.FileEdit; + /** URI of the file content before the edit */ + beforeURI: URI; + /** URI of the file content after the edit */ + afterURI: URI; + /** Optional diff display metadata */ + diff?: { + /** Number of items added (e.g., lines for text files, cells for notebooks) */ + added?: number; + /** Number of items removed (e.g., lines for text files, cells for notebooks) */ + removed?: number; + }; +} + /** * Content block in a tool result. * - * Mirrors the content blocks in MCP `CallToolResult.content`, plus `IContentRef` - * for lazy-loading large results (an AHP extension). + * Mirrors the content blocks in MCP `CallToolResult.content`, plus + * `IContentRef` for lazy-loading large results and `IToolResultFileEditContent` + * for file edit diffs (AHP extensions). * * @category Tool Result Content */ export type IToolResultContent = | IToolResultTextContent | IToolResultBinaryContent + | IToolResultFileEditContent | IContentRef; // ─── Permission Types ──────────────────────────────────────────────────────── 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 94193f19930..1e6dcd41b1c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ActionType, type IStateAction } from '../actions.js'; import { NotificationType, type IProtocolNotification } from '../notifications.js'; @@ -69,6 +69,7 @@ export function isActionKnownToVersion(action: IStateAction, clientVersion: numb export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification['type']]: number } = { [NotificationType.SessionAdded]: 1, [NotificationType.SessionRemoved]: 1, + [NotificationType.AuthRequired]: 1, }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index e1242a2a995..7cf64cba44a 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -49,8 +49,10 @@ export { export { NotificationType, + AuthRequiredReason, type ISessionAddedNotification, type ISessionRemovedNotification, + type IAuthRequiredNotification, } from './protocol/notifications.js'; // ---- Local aliases for short names ------------------------------------------ diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index e49688b0f44..deca30524f4 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -80,6 +80,7 @@ export const AHP_SESSION_ALREADY_EXISTS = -32003 as const; export const AHP_TURN_IN_PROGRESS = -32004 as const; export const AHP_UNSUPPORTED_PROTOCOL_VERSION = -32005 as const; export const AHP_CONTENT_NOT_FOUND = -32006 as const; +export const AHP_AUTH_REQUIRED = -32007 as const; // ---- Type guards ----------------------------------------------------------- @@ -101,9 +102,10 @@ export function isJsonRpcResponse(msg: IProtocolMessage): msg is IAhpSuccessResp /** * Error with a JSON-RPC error code for protocol-level failures. + * Optionally carries a `data` payload for structured error details. */ export class ProtocolError extends Error { - constructor(readonly code: number, message: string) { + constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); } } diff --git a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts index ed6aa21ebcd..5412b4f608b 100644 --- a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts +++ b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts @@ -54,6 +54,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe export const NOTIFICATION_INTRODUCED_IN: { readonly [K in INotification['type']]: number } = { 'notify/sessionAdded': 1, 'notify/sessionRemoved': 1, + 'notify/authRequired': 1, }; // ---- Runtime filtering helpers ---------------------------------------------- diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index d0667c84b85..da706baae2f 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -13,7 +13,7 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js' import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -83,8 +83,11 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- - setAuthToken(token: string): Promise { - return this._proxy.setAuthToken(token); + getResourceMetadata(): Promise { + return this._proxy.getResourceMetadata(); + } + authenticate(params: IAuthenticateParams): Promise { + return this._proxy.authenticate(params); } listAgents(): Promise { return this._proxy.listAgents(); diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index c05b080a7a4..b2cfa7eee0c 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -14,7 +14,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -87,7 +87,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC clientId: this._clientId, }); this._serverSeq = result.serverSeq; - this._defaultDirectory = result.defaultDirectory; + // defaultDirectory arrives from the protocol as either a URI string + // (e.g. "file:///Users/roblou") or a serialized URI object + // ({ scheme, path, ... }). Extract just the filesystem path. + if (result.defaultDirectory) { + const dir = result.defaultDirectory; + if (typeof dir === 'string') { + this._defaultDirectory = URI.parse(dir).path; + } else { + this._defaultDirectory = URI.revive(dir).path; + } + } } /** @@ -128,10 +138,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } /** - * Push a GitHub auth token to the remote agent host. + * Retrieve the server's resource metadata describing auth requirements. */ - async setAuthToken(token: string): Promise { - this._sendExtensionNotification('setAuthToken', { token }); + async getResourceMetadata(): Promise { + return await this._sendExtensionRequest('getResourceMetadata') as IResourceMetadata; + } + + /** + * Authenticate with the remote agent host using a specific scheme. + */ + async authenticate(params: IAuthenticateParams): Promise { + return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult; } /** @@ -172,6 +189,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC startTime: s.createdAt, modifiedTime: s.modifiedAt, summary: s.title, + workingDirectory: typeof s.workingDirectory === 'string' ? s.workingDirectory : undefined, })); } @@ -227,13 +245,6 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._transport.send({ jsonrpc: '2.0' as const, method, params } as IProtocolMessage); } - /** Send a JSON-RPC notification for a VS Code extension method (not in the protocol spec). */ - private _sendExtensionNotification(method: string, params?: unknown): void { - // Cast: extension methods aren't in the typed protocol maps yet - // eslint-disable-next-line local/code-no-dangerous-type-assertions - this._transport.send({ jsonrpc: '2.0', method, params } as unknown as IJsonRpcResponse); - } - /** Send a typed JSON-RPC request for a protocol-defined method. */ private _sendRequest(method: M, params: ICommandMap[M]['params']): Promise { const id = this._nextRequestId++; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 498155d598b..20df9b72167 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -145,10 +145,15 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog status: SessionStatus.Idle, createdAt: s.startTime, modifiedAt: s.modifiedTime, + workingDirectory: s.workingDirectory, })); }, - handleSetAuthToken(token) { - agentService.setAuthToken(token); + + handleGetResourceMetadata() { + return agentService.getResourceMetadataSync(); + }, + async handleAuthenticate(params) { + return agentService.authenticate(params); }, handleBrowseDirectory(uri) { return agentService.browseDirectory(URI.parse(uri)); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index ab8b67a7b72..7dad86b7ad3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7,10 +7,10 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; -import { ILogService } from '../../log/common/log.js'; import { IFileService } from '../../files/common/files.js'; -import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor } from '../common/agentService.js'; -import { ActionType, type IActionEnvelope, type INotification, type ISessionAction } from '../common/state/sessionActions.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; import { AgentSideEffects } from './agentSideEffects.js'; @@ -89,13 +89,28 @@ export class AgentService extends Disposable implements IAgentService { return [...this._providers.values()].map(p => p.getDescriptor()); } - async setAuthToken(token: string): Promise { - this._logService.trace('[AgentService] setAuthToken called'); - const promises: Promise[] = []; + async getResourceMetadata(): Promise { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + getResourceMetadataSync(): IResourceMetadata { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + async authenticate(params: IAuthenticateParams): Promise { + this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); for (const provider of this._providers.values()) { - promises.push(provider.setAuthToken(token)); + const resources = provider.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await provider.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } } - await Promise.all(promises); + return { authenticated: false }; } // ---- session management ------------------------------------------------- @@ -138,6 +153,7 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: config?.workingDirectory, }; this._stateManager.createSession(summary); this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index c3cd4f362e6..efe3e9c8276 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -3,18 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as os from 'os'; import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; -import * as os from 'os'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgent, IAgentAttachment } from '../common/agentService.js'; -import { ActionType, type ISessionAction } from '../common/state/sessionActions.js'; -import { IBrowseDirectoryResult, ICreateSessionParams, AHP_PROVIDER_NOT_FOUND, JSON_RPC_INTERNAL_ERROR, ProtocolError, IDirectoryEntry } from '../common/state/sessionProtocol.js'; +import { IAgent, IAgentAttachment, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; +import { AHP_PROVIDER_NOT_FOUND, IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../common/state/sessionProtocol.js'; import { + SessionStatus, type ISessionModelInfo, - SessionStatus, type ISessionSummary, type URI as ProtocolURI, + type ISessionSummary, type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { mapProgressEventToActions } from './agentEventMapper.js'; import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; @@ -198,6 +199,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: command.workingDirectory, }; this._stateManager.createSession(summary); this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session }); @@ -228,12 +230,22 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH return allSessions; } - handleSetAuthToken(token: string): void { + handleGetResourceMetadata(): IResourceMetadata { + const resources = this._options.agents.get().flatMap(a => a.getProtectedResources()); + return { resources }; + } + + async handleAuthenticate(params: IAuthenticateParams): Promise { for (const agent of this._options.agents.get()) { - agent.setAuthToken(token).catch(err => { - this._logService.error('[AgentSideEffects] setAuthToken failed', err); - }); + const resources = agent.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await agent.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } } + return { authenticated: false }; } async handleBrowseDirectory(uri: ProtocolURI): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 334edbba1d4..95c0d966482 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4,19 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk'; +import { rgPath } from '@vscode/ripgrep'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; -import { rgPath } from '@vscode/ripgrep'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../log/common/log.js'; -import { IAgentCreateSessionConfig, IAgentModelInfo, IAgentProgressEvent, IAgentMessageEvent, IAgent, IAgentSessionMetadata, IAgentToolStartEvent, IAgentToolCompleteEvent, AgentSession, IAgentDescriptor, IAgentAttachment } from '../../common/agentService.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { PermissionKind, type PolicyState } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; function tryStringify(value: unknown): string | undefined { try { @@ -63,7 +64,19 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - async setAuthToken(token: string): Promise { + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return [{ + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + }]; + } + + async authenticate(resource: string, token: string): Promise { + if (resource !== 'https://api.github.com') { + return false; + } const tokenChanged = this._githubToken !== token; this._githubToken = token; this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); @@ -74,6 +87,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._clientStarting = undefined; await client.stop(); } + return true; } // ---- client lifecycle --------------------------------------------------- @@ -142,11 +156,12 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing sessions...'); const client = await this._ensureClient(); const sessions = await client.listSessions(); - const result = sessions.map(s => ({ + const result: IAgentSessionMetadata[] = sessions.map(s => ({ session: AgentSession.uri(this.id, s.sessionId), startTime: s.startTime.getTime(), modifiedTime: s.modifiedTime.getTime(), summary: s.summary, + workingDirectory: typeof s.context?.cwd === 'string' ? s.context.cwd : undefined, })); this._logService.info(`[Copilot] Found ${result.length} sessions`); return result; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index a2fb3c2d748..a7e1ecc5b59 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; +import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { ICommandMap } from '../common/state/protocol/messages.js'; import { IActionEnvelope, INotification, isSessionAction, type ISessionAction } from '../common/state/sessionActions.js'; import { isActionKnownToVersion, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -21,7 +22,6 @@ import { type IInitializeParams, type IJsonRpcResponse, type IReconnectParams, - type ISetAuthTokenParams, type IStateSnapshot, } from '../common/state/sessionProtocol.js'; import { ROOT_STATE_URI, type ISessionSummary, type URI } from '../common/state/sessionState.js'; @@ -37,15 +37,24 @@ function jsonRpcSuccess(id: number, result: unknown): IJsonRpcResponse { } /** Build a JSON-RPC error response suitable for transport.send(). */ -function jsonRpcError(id: number, code: number, message: string): IJsonRpcResponse { - return { jsonrpc: '2.0', id, error: { code, message } }; +function jsonRpcError(id: number, code: number, message: string, data?: unknown): IJsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; +} + +/** Build a JSON-RPC error response from an unknown thrown value, preserving {@link ProtocolError} fields. */ +function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { + if (err instanceof ProtocolError) { + return jsonRpcError(id, err.code, err.message, err.data); + } + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); } /** * Methods handled by the request dispatcher. Excludes `initialize` and * `reconnect` which are handled during the handshake phase. */ -type RequestMethod = Exclude; +type RequestMethod = Exclude; /** * Typed handler map: each key is a request method, each value is a handler @@ -119,9 +128,7 @@ export class ProtocolServerHandler extends Disposable { client = result.client; transport.send(jsonRpcSuccess(msg.id, result.response)); } catch (err) { - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof Error ? err.message : String(err); - transport.send(jsonRpcError(msg.id, code, message)); + transport.send(jsonRpcErrorFrom(msg.id, err)); } return; } @@ -131,9 +138,7 @@ export class ProtocolServerHandler extends Disposable { client = result.client; transport.send(jsonRpcSuccess(msg.id, result.response)); } catch (err) { - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof Error ? err.message : String(err); - transport.send(jsonRpcError(msg.id, code, message)); + transport.send(jsonRpcErrorFrom(msg.id, err)); } return; } @@ -160,15 +165,6 @@ export class ProtocolServerHandler extends Disposable { this._sideEffectHandler.handleAction(action); } break; - default: { - // VS Code extension: setAuthToken (not part of the protocol spec) - const method = msg.method as string; - if (method === 'setAuthToken') { - const p = msg.params as unknown as ISetAuthTokenParams; - this._sideEffectHandler.handleSetAuthToken(p.token); - } - break; - } } } // Responses from the client (if any) are ignored on the server side. @@ -336,23 +332,52 @@ export class ProtocolServerHandler extends Disposable { private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { const handler = this._requestHandlers.hasOwnProperty(method) ? this._requestHandlers[method as RequestMethod] : undefined; - if (!handler) { - client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + if (handler) { + (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { + this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + client.transport.send(jsonRpcErrorFrom(id, err)); + }); return; } - (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { - this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); - client.transport.send(jsonRpcSuccess(id, result ?? null)); - }).catch(err => { - this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof ProtocolError - ? err.message - : err instanceof Error && err.stack - ? err.stack - : String(err?.message ?? err); - client.transport.send(jsonRpcError(id, code, message)); - }); + + // VS Code extension methods (not in the typed protocol maps yet) + const extensionResult = this._handleExtensionRequest(method, params); + if (extensionResult) { + extensionResult.then(result => { + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Extension request '${method}' failed`, err); + client.transport.send(jsonRpcErrorFrom(id, err)); + }); + return; + } + + client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + } + + /** + * Handle VS Code extension methods that are not yet part of the typed + * protocol. Returns a Promise if the method was recognized, undefined + * otherwise. + */ + private _handleExtensionRequest(method: string, params: unknown): Promise | undefined { + switch (method) { + case 'getResourceMetadata': + return Promise.resolve(this._sideEffectHandler.handleGetResourceMetadata()); + case 'authenticate': + return this._sideEffectHandler.handleAuthenticate(params as IAuthenticateParams); + case 'refreshModels': + return this._sideEffectHandler.handleRefreshModels?.() ?? Promise.resolve(null); + case 'listAgents': + return Promise.resolve(this._sideEffectHandler.handleListAgents?.() ?? []); + case 'shutdown': + return this._sideEffectHandler.handleShutdown?.() ?? Promise.resolve(null); + default: + return undefined; + } } // ---- Broadcasting ------------------------------------------------------- @@ -407,8 +432,15 @@ export interface IProtocolSideEffectHandler { handleCreateSession(command: ICreateSessionParams): Promise; handleDisposeSession(session: URI): void; handleListSessions(): Promise; - handleSetAuthToken(token: string): void; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; /** Returns the server's default browsing directory, if available. */ getDefaultDirectory?(): URI; + /** Refresh models from all providers (VS Code extension method). */ + handleRefreshModels?(): Promise; + /** List agent descriptors (VS Code extension method). */ + handleListAgents?(): IAgentDescriptor[]; + /** Shut down all providers (VS Code extension method). */ + handleShutdown?(): Promise; } diff --git a/src/vs/platform/agentHost/test/auth-rework.md b/src/vs/platform/agentHost/test/auth-rework.md new file mode 100644 index 00000000000..4533c3b4e59 --- /dev/null +++ b/src/vs/platform/agentHost/test/auth-rework.md @@ -0,0 +1,454 @@ +# Auth Rework: Standards-Based Authentication for the Agent Host Protocol + +## Problem + +The current authentication mechanism is imperative and VS Code-specific: + +1. The renderer discovers agents via `listAgents()` and checks `IAgentDescriptor.requiresAuth`. +2. It obtains a GitHub OAuth token from VS Code's built-in authentication service. +3. It pushes the token via `setAuthToken(token)` — a fire-and-forget JSON-RPC notification. +4. The agent host fans the token out to all registered `IAgent` providers. + +This couples the agent host to VS Code internals. An external client (CLI tool, web app, another editor) connecting over WebSocket has no way to know _what_ authentication is required, _where_ to get a token, or _what scopes_ are needed. The client must have out-of-band knowledge that "this server needs a GitHub OAuth token." + +## Design Goals + +- **Self-describing**: The server declares its auth requirements so arbitrary clients can discover them without prior knowledge of the server's internals. +- **Standards-aligned**: Use the semantics and vocabulary of RFC 6750 (Bearer Token Usage) and RFC 9728 (OAuth 2.0 Protected Resource Metadata) adapted for JSON-RPC. +- **Challenge-on-failure**: When auth is missing or invalid, the server responds with a structured challenge (like `WWW-Authenticate`) that tells the client exactly what to do. +- **Transport-agnostic**: Works over WebSocket JSON-RPC and MessagePort IPC alike. +- **Multi-provider**: Supports multiple independent auth requirements (e.g. GitHub + a future enterprise IdP) each with their own scopes and authorization servers. +- **Non-breaking migration**: Can coexist with `setAuthToken` during a transition period. + +## Relevant Standards + +### RFC 6750 — Bearer Token Usage + +Defines how bearer tokens are transmitted (`Authorization: Bearer `) and how servers challenge clients when auth is missing or invalid: + +``` +WWW-Authenticate: Bearer realm="example", + error="invalid_token", + error_description="The access token expired" +``` + +Key error codes: `invalid_request`, `invalid_token`, `insufficient_scope`. + +### RFC 9728 — OAuth 2.0 Protected Resource Metadata + +Defines a metadata document that a protected resource publishes to describe itself: + +```json +{ + "resource": "https://resource.example.com", + "authorization_servers": ["https://as.example.com"], + "scopes_supported": ["profile", "email"], + "bearer_methods_supported": ["header"] +} +``` + +Clients discover this metadata either via a well-known URL or via the `resource_metadata` parameter in a `WWW-Authenticate` challenge. This tells the client _where_ to get a token and _what scopes_ to request. + +## Proposed Design + +### Overview + +The authentication flow has three phases, mirroring the HTTP flow from RFC 9728 §5: + +``` +┌─────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Client │ │ Agent Host │ │ Authorization │ +│ │ │ (Server) │ │ Server │ +└────┬─────┘ └──────┬───────┘ └────────┬────────┘ + │ │ │ + │ 1. initialize │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 2. initialize result │ │ + │ { auth: [{ scheme, resource, │ │ + │ authorization_servers, │ │ + │ scopes_supported }] } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 3. Obtain token from AS │ │ + │ ─────────────────────────────────────────────────────────────────> │ + │ │ │ + │ 4. Token │ │ + │ <───────────────────────────────────────────────────────────────── │ + │ │ │ + │ 5. authenticate { scheme, token } │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 6. { authenticated: true } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 7. createSession / other commands │ │ + │ ───────────────────────────────────> │ │ +``` + +### Phase 1: Discovery (in `initialize` response) + +The `initialize` result is extended with a `resourceMetadata` field, modeled on RFC 9728 §2: + +```typescript +interface IInitializeResult { + protocolVersion: number; + serverSeq: number; + snapshots: ISnapshot[]; + defaultDirectory?: URI; + + /** RFC 9728-style resource metadata describing auth requirements. */ + resourceMetadata?: IResourceMetadata; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ +interface IResourceMetadata { + /** + * Identifier for this resource (the agent host). + * Analogous to RFC 9728 `resource`. + */ + resource: string; + + /** + * Independent auth requirements. Each entry describes one + * authentication scheme the server accepts. A client must + * satisfy at least one to use authenticated features. + */ + authSchemes: IAuthScheme[]; +} + +/** + * A single authentication scheme the server accepts. + */ +interface IAuthScheme { + /** + * The auth scheme name. Initially only "bearer" (RFC 6750). + * Future schemes (e.g. "dpop", "device_code") can be added. + */ + scheme: 'bearer'; + + /** + * An opaque identifier for this auth requirement, used to + * correlate `authenticate` calls and challenges. Allows the + * server to require multiple independent tokens (e.g. one + * per agent provider). + * + * Example: "github" for GitHub Copilot auth. + */ + id: string; + + /** + * Human-readable label for display in auth UIs. + * Analogous to RFC 9728 `resource_name`. + */ + label: string; + + /** + * Authorization server issuer identifiers (RFC 8414). + * Tells the client where to obtain tokens. + * Analogous to RFC 9728 `authorization_servers`. + * + * Example: ["https://github.com/login/oauth"] + */ + authorizationServers: string[]; + + /** + * OAuth scopes the server needs. + * Analogous to RFC 9728 `scopes_supported`. + * + * Example: ["read:user", "user:email", "repo", "workflow"] + */ + scopesSupported?: string[]; + + /** + * Whether this auth requirement is mandatory for any + * functionality, or only for specific agents/features. + */ + required?: boolean; +} +``` + +**Why in `initialize`?** RFC 9728 publishes metadata at a well-known URL. In our JSON-RPC world, the `initialize` handshake _is_ the well-known endpoint — it's the first thing every client calls, and it's already where we exchange capabilities. This avoids an extra round-trip and keeps the discovery atomic. + +### Phase 2: Token Delivery (`authenticate` command) + +Replace the fire-and-forget `setAuthToken` notification with a proper JSON-RPC **request** so the client gets confirmation: + +```typescript +/** + * Client → Server request to authenticate. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 §2.1). + */ +interface IAuthenticateParams { + /** + * The auth scheme identifier from the server's resourceMetadata. + * Correlates to IAuthScheme.id. + */ + schemeId: string; + + /** The scheme type (initially always "bearer"). */ + scheme: 'bearer'; + + /** The bearer token value (RFC 6750). */ + token: string; +} + +interface IAuthenticateResult { + /** Whether the token was accepted. */ + authenticated: boolean; +} +``` + +This is a **request** (not a notification) so: +- The client knows immediately if the token was accepted or rejected. +- The server can validate the token before returning success. +- Errors use structured challenges (see Phase 3). + +The client can call `authenticate` multiple times (e.g. when a token is refreshed), and can authenticate for multiple scheme IDs independently. + +### Phase 3: Challenges on Failure + +When a command fails because authentication is missing or invalid, the server returns a JSON-RPC error with structured challenge data in the `data` field, modeled on RFC 6750 §3: + +```typescript +/** + * JSON-RPC error data for authentication failures. + * Modeled on RFC 6750 WWW-Authenticate challenge parameters. + */ +interface IAuthChallenge { + /** The scheme ID that needs (re-)authentication. */ + schemeId: string; + + /** RFC 6750 §3.1 error code. */ + error: 'invalid_request' | 'invalid_token' | 'insufficient_scope'; + + /** Human-readable error description (RFC 6750 §3 error_description). */ + errorDescription?: string; + + /** Required scopes, if the error is insufficient_scope (RFC 6750 §3 scope). */ + scope?: string; +} +``` + +This is returned as the `data` payload of a JSON-RPC error response: + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32007, + "message": "Authentication required", + "data": { + "challenges": [ + { + "schemeId": "github", + "error": "invalid_token", + "errorDescription": "The access token expired" + } + ] + } + } +} +``` + +A dedicated error code (`-32007 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. + +### Phase 4: Auth State Notifications + +The server pushes auth state changes via notifications so clients know when auth expires or the required scopes change: + +```typescript +/** + * Server → Client notification when auth state changes. + */ +interface IAuthStateNotification { + type: 'notify/authRequired'; + + /** The scheme ID whose auth state changed. */ + schemeId: string; + + /** The new state. */ + state: 'authenticated' | 'expired' | 'revoked' | 'required'; + + /** Optional challenge with details (e.g. new scopes needed). */ + challenge?: IAuthChallenge; +} +``` + +This replaces the implicit "push a token whenever you see an account change" model with an explicit server-driven signal. + +## Concrete Example: GitHub Copilot Auth + +### Server-side (CopilotAgent) + +When the Copilot agent registers, it publishes an auth scheme: + +```typescript +// In CopilotAgent.getAuthSchemes(): +[{ + scheme: 'bearer', + id: 'github', + label: 'GitHub', + authorizationServers: ['https://github.com/login/oauth'], + scopesSupported: ['read:user', 'user:email'], + required: true, +}] +``` + +The agent host aggregates auth schemes from all agents into `IInitializeResult.resourceMetadata`. + +### Client-side (VS Code renderer) + +```typescript +// After initialize: +const metadata = initResult.resourceMetadata; +if (metadata) { + for (const scheme of metadata.authSchemes) { + if (scheme.scheme === 'bearer' && scheme.authorizationServers.some( + as => as.includes('github.com') + )) { + // We know how to handle GitHub auth + const token = await this._getGitHubToken(scheme.scopesSupported); + await agentHostService.authenticate({ + schemeId: scheme.id, + scheme: 'bearer', + token, + }); + } + } +} +``` + +### Client-side (generic external client) + +A CLI tool connecting over WebSocket: + +```typescript +const ws = new WebSocket('ws://localhost:3000'); +const initResult = await rpc.request('initialize', { protocolVersion: 1, clientId: 'cli-1' }); + +for (const scheme of initResult.resourceMetadata?.authSchemes ?? []) { + if (scheme.scheme === 'bearer') { + console.log(`Auth required: ${scheme.label}`); + console.log(`Get a token from: ${scheme.authorizationServers[0]}`); + console.log(`Scopes: ${scheme.scopesSupported?.join(', ')}`); + + // Client can use any OAuth library to get the token + const token = await doOAuthFlow(scheme.authorizationServers[0], scheme.scopesSupported); + await rpc.request('authenticate', { schemeId: scheme.id, scheme: 'bearer', token }); + } +} +``` + +## Protocol Changes Summary + +### New JSON-RPC request: `authenticate` + +| Direction | Type | Params | Result | +|---|---|---|---| +| Client → Server | Request | `IAuthenticateParams` | `IAuthenticateResult` | + +### New JSON-RPC error code + +| Code | Name | When | +|---|---|---| +| `-32007` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | + +### Extended: `initialize` result + +| Field | Type | Description | +|---|---|---| +| `resourceMetadata` | `IResourceMetadata` | Optional. Auth and resource information. | + +### New notification + +| Type | Direction | When | +|---|---|---| +| `notify/authRequired` | Server → Client | Auth state changed (expired, revoked, new requirements) | + +### Deprecated + +| Item | Replacement | Migration | +|---|---|---| +| `setAuthToken` notification | `authenticate` request | Keep accepting `setAuthToken` for one version, log deprecation | +| `IAgentDescriptor.requiresAuth` | `IResourceMetadata.authSchemes` | Derive from `authSchemes` during transition | + +## Interface Changes in `agentService.ts` + +### `IAgentService` + +```diff + interface IAgentService { +- setAuthToken(token: string): Promise; ++ authenticate(params: IAuthenticateParams): Promise; + } +``` + +### `IAgent` + +```diff + interface IAgent { +- setAuthToken(token: string): Promise; ++ /** Declare auth schemes this agent requires. */ ++ getAuthSchemes(): IAuthScheme[]; ++ /** Authenticate with a specific scheme. Returns true if accepted. */ ++ authenticate(schemeId: string, token: string): Promise; + } +``` + +### `IAgentDescriptor` + +```diff + interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; +- readonly requiresAuth: boolean; + } +``` + +`requiresAuth` is removed — clients discover auth requirements from `IResourceMetadata` instead of per-agent descriptors. + +## Design Decisions + +### Why not `WWW-Authenticate` headers literally? + +We're not using HTTP. Embedding RFC 6750's string-encoded header format in JSON-RPC would be awkward. Instead, we use JSON-native equivalents with the same semantics: `IAuthChallenge` mirrors the `WWW-Authenticate` parameters, and `IResourceMetadata` mirrors RFC 9728's metadata document. + +### Why in `initialize` and not a separate `getResourceMetadata` command? + +Fewer round-trips. Every client calls `initialize` first — embedding auth requirements there means the client knows what auth is needed from the very first response. A separate command would add latency and complexity for zero benefit, since the metadata is small and always needed. + +### Why `schemeId` and not just the `scheme` name? + +A server might need multiple bearer tokens from different authorization servers (e.g. GitHub + an enterprise IdP). The `schemeId` lets the client and server correlate tokens to specific requirements. It also makes `authenticate` calls idempotent and unambiguous. + +### Why a request instead of a notification for `authenticate`? + +The current `setAuthToken` is fire-and-forget — the client has no idea if the token was accepted, expired, or for the wrong provider. Making `authenticate` a request with a response lets the client react immediately (retry with different scopes, prompt the user, etc.). + +### What about Device Code / OAuth flows that the server drives? + +This proposal covers the "client already has a token" case (RFC 6750 bearer). For server-driven flows (device code, authorization code with redirect), the `authorizationServers` metadata tells the client which AS to talk to. The actual OAuth flow is client-side — the server just declares requirements. + +A future extension could add an `IAuthScheme` with `scheme: 'device_code'` that includes a device authorization endpoint, letting the server guide the client through a device flow. This is out of scope for the initial implementation. + +## Migration Plan + +1. **Phase A**: Add `resourceMetadata` to `IInitializeResult` and the `authenticate` command. Keep `setAuthToken` working as-is. +2. **Phase B**: Update VS Code renderer to use `authenticate` instead of `setAuthToken`. External clients can start using the new flow. +3. **Phase C**: Remove `setAuthToken`, `requiresAuth`, and the old imperative push model. Bump protocol version. + +## Open Questions + +1. **Token validation**: Should the server validate tokens eagerly on `authenticate` (e.g. call a GitHub API endpoint), or defer validation to when a command actually needs it? Eager validation gives better error messages; deferred is simpler and avoids extra network calls. + +2. **Per-agent vs. global auth**: The current design has one `resourceMetadata` for the whole server. Should auth schemes be per-agent-provider instead? Per-agent gives finer control (e.g. "Copilot needs GitHub, MockAgent needs nothing") but complicates the protocol. The current proposal uses global metadata with `schemeId` correlation, which the server can internally route to the right agent. + +3. **Token refresh**: Should the server expose token expiry information so clients can proactively refresh, or rely on `notify/authRequired` to signal when a refresh is needed? Proactive refresh avoids interruptions but requires the server to parse tokens (which it shouldn't have to for opaque tokens). + +4. **Multiple tokens**: Can a client authenticate multiple scheme IDs simultaneously? (Proposed: yes.) Can multiple clients each send their own token? (Proposed: yes, last-writer-wins per schemeId, which matches current behavior.) diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index dd71dfbaa89..72b776d5e86 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -126,20 +126,6 @@ suite('AgentService (node dispatcher)', () => { }); }); - // ---- setAuthToken --------------------------------------------------- - - suite('setAuthToken', () => { - - test('broadcasts token to all registered providers', async () => { - service.registerProvider(copilotAgent); - - await service.setAuthToken('my-token'); - - assert.strictEqual(copilotAgent.setAuthTokenCalls.length, 1); - assert.strictEqual(copilotAgent.setAuthTokenCalls[0], 'my-token'); - }); - }); - // ---- listSessions / listModels -------------------------------------- suite('aggregation', () => { @@ -169,6 +155,54 @@ suite('AgentService (node dispatcher)', () => { }); }); + // ---- getResourceMetadata -------------------------------------------- + + suite('getResourceMetadata', () => { + + test('aggregates protected resources from all providers', async () => { + service.registerProvider(copilotAgent); + + const mockAgent = new MockAgent('other'); + disposables.add(toDisposable(() => mockAgent.dispose())); + service.registerProvider(mockAgent); + + const metadata = await service.getResourceMetadata(); + // copilot agent returns one resource (https://api.github.com), + // generic MockAgent('other') returns empty + assert.deepStrictEqual(metadata, { + resources: [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }], + }); + }); + + test('returns empty resources when no providers registered', async () => { + const metadata = await service.getResourceMetadata(); + assert.deepStrictEqual(metadata, { resources: [] }); + }); + }); + + // ---- authenticate --------------------------------------------------- + + suite('authenticate', () => { + + test('routes token to provider matching the resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' }); + + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]); + }); + + test('returns not authenticated for unknown resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' }); + + assert.deepStrictEqual(result, { authenticated: false }); + assert.strictEqual(copilotAgent.authenticateCalls.length, 0); + }); + }); + // ---- shutdown ------------------------------------------------------- suite('shutdown', () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 86f9284b2a7..72536646a26 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -322,4 +322,43 @@ suite('AgentSideEffects', () => { assert.ok(action, 'should dispatch root/agentsChanged'); }); }); + + // ---- handleGetResourceMetadata / handleAuthenticate ----------------- + + suite('auth', () => { + + test('handleGetResourceMetadata aggregates resources from agents', () => { + agentList.set([agent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 0, 'mock agent has no protected resources'); + }); + + test('handleGetResourceMetadata returns resources when agent declares them', () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 1); + assert.strictEqual(metadata.resources[0].resource, 'https://api.github.com'); + }); + + test('handleAuthenticate returns authenticated for matching resource', async () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://api.github.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'test-token' }]); + }); + + test('handleAuthenticate returns not authenticated for non-matching resource', async () => { + agentList.set([agent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://unknown.example.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: false }); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index bea9ddcff28..0bd861655f6 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -5,6 +5,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; import { PermissionKind } from '../../common/state/sessionState.js'; @@ -19,12 +20,13 @@ export class MockAgent implements IAgent { private readonly _sessions = new Map(); private _nextId = 1; - readonly setAuthTokenCalls: string[] = []; + readonly sendMessageCalls: { session: URI; prompt: string }[] = []; readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; readonly changeModelCalls: { session: URI; model: string }[] = []; + readonly authenticateCalls: { resource: string; token: string }[] = []; constructor(readonly id: AgentProvider = 'mock') { } @@ -32,6 +34,13 @@ export class MockAgent implements IAgent { return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + if (this.id === 'copilot') { + return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }]; + } + return []; + } + async listModels(): Promise { return [{ provider: this.id, id: `${this.id}-model`, name: `${this.id} Model`, maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -72,8 +81,9 @@ export class MockAgent implements IAgent { this.changeModelCalls.push({ session, model }); } - async setAuthToken(token: string): Promise { - this.setAuthTokenCalls.push(token); + async authenticate(resource: string, token: string): Promise { + this.authenticateCalls.push({ resource, token }); + return true; } async shutdown(): Promise { } @@ -105,6 +115,10 @@ export class ScriptedMockAgent implements IAgent { return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return []; + } + async listModels(): Promise { return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -224,7 +238,9 @@ export class ScriptedMockAgent implements IAgent { } } - async setAuthToken(_token: string): Promise { } + async authenticate(_resource: string, _token: string): Promise { + return true; + } async shutdown(): Promise { } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 9385ce8f6b7..3eac4562547 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -74,7 +74,8 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { async handleCreateSession(_command: ICreateSessionParams): Promise { /* session created via state manager */ } handleDisposeSession(_session: string): void { } async handleListSessions(): Promise { return []; } - handleSetAuthToken(_token: string): void { } + handleGetResourceMetadata() { return { resources: [] }; } + async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; } async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> { this.browsedUris.push(URI.parse(uri)); const error = this.browseErrors.get(uri); @@ -380,4 +381,50 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(resp.error!.code, JSON_RPC_INTERNAL_ERROR); assert.match(resp.error!.message, /Directory not found/); }); + + // ---- Extension methods: auth ---------------------------------------- + + test('getResourceMetadata returns resource metadata via extension request', async () => { + const transport = connectClient('client-metadata'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'getResourceMetadata')); + const resp = await responsePromise as { result?: { resources: unknown[] } }; + + assert.ok(resp?.result); + assert.ok(Array.isArray(resp.result!.resources)); + }); + + test('authenticate returns result via extension request', async () => { + const transport = connectClient('client-auth'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' })); + const resp = await responsePromise as { result?: { authenticated: boolean } }; + + assert.ok(resp?.result); + assert.strictEqual(resp.result!.authenticated, true); + }); + + test('extension request preserves ProtocolError code and data', async () => { + // Override handleAuthenticate to throw a ProtocolError with data + const origHandler = sideEffects.handleAuthenticate; + sideEffects.handleAuthenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); }; + + const transport = connectClient('client-auth-error'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'test', token: 'bad' })); + const resp = await responsePromise as { error?: { code: number; message: string; data?: unknown } }; + + assert.ok(resp?.error); + assert.strictEqual(resp.error!.code, -32007); + assert.strictEqual(resp.error!.message, 'Auth required'); + assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' }); + + sideEffects.handleAuthenticate = origHandler; + }); }); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index cb5a7fb8017..1cf026d1c75 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -10,21 +10,37 @@ import { localize } from '../../../nls.js'; const commandPrefix = 'workbench.action.browser'; export enum BrowserViewCommandId { + // Tab management Open = `${commandPrefix}.open`, NewTab = `${commandPrefix}.newTab`, + QuickOpen = `${commandPrefix}.quickOpen`, + CloseAll = `${commandPrefix}.closeAll`, + CloseAllInGroup = `${commandPrefix}.closeAllInGroup`, + + // Navigation GoBack = `${commandPrefix}.goBack`, GoForward = `${commandPrefix}.goForward`, Reload = `${commandPrefix}.reload`, HardReload = `${commandPrefix}.hardReload`, + + // Editor actions FocusUrlInput = `${commandPrefix}.focusUrlInput`, + OpenExternal = `${commandPrefix}.openExternal`, + OpenSettings = `${commandPrefix}.openSettings`, + + // Chat actions AddElementToChat = `${commandPrefix}.addElementToChat`, AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, + + // Dev Tools ToggleDevTools = `${commandPrefix}.toggleDevTools`, - OpenExternal = `${commandPrefix}.openExternal`, + + // Storage ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, - OpenSettings = `${commandPrefix}.openSettings`, + + // Find in page ShowFind = `${commandPrefix}.showFind`, HideFind = `${commandPrefix}.hideFind`, FindNext = `${commandPrefix}.findNext`, diff --git a/src/vs/platform/browserView/common/browserViewTelemetry.ts b/src/vs/platform/browserView/common/browserViewTelemetry.ts index 66853e50999..0f5037b877b 100644 --- a/src/vs/platform/browserView/common/browserViewTelemetry.ts +++ b/src/vs/platform/browserView/common/browserViewTelemetry.ts @@ -17,6 +17,10 @@ export type IntegratedBrowserOpenSource = /** Opened via the "Open Integrated Browser" command with a URL argument. * This typically means another extension or component invoked the command programmatically. */ | 'commandWithUrl' + /** Opened via the quick open feature with no initial URL. */ + | 'quickOpenWithoutUrl' + /** Opened via the quick open feature with an initial URL. */ + | 'quickOpenWithUrl' /** Opened via the "New Tab" command from an existing tab. */ | 'newTabCommand' /** Opened via the localhost link opener when the `workbench.browser.openLocalhostLinks` setting diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 7af7bce71bc..72752cf9ed1 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -24,6 +24,7 @@ export interface NativeParsedArgs { }; }; 'serve-web'?: INativeCliOptions; + 'agent-host'?: INativeCliOptions; chat?: { _: string[]; 'add-file'?: string[]; @@ -109,6 +110,7 @@ export interface NativeParsedArgs { 'disable-telemetry'?: boolean; 'export-default-configuration'?: string; 'export-policy-data'?: string; + 'export-default-keybindings'?: string; 'install-source'?: string; 'add-mcp'?: string[]; 'disable-updates'?: boolean; diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 137a08dab33..883ed24b0fc 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -149,6 +149,7 @@ export interface INativeEnvironmentService extends IEnvironmentService { crossOriginIsolated?: boolean; exportPolicyData?: string; + exportDefaultKeybindings?: string; // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index c6869a109f1..3502a718fa0 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -264,6 +264,10 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return this.args['export-policy-data']; } + get exportDefaultKeybindings(): string | undefined { + return this.args['export-default-keybindings']; + } + get continueOn(): string | undefined { return this.args['continueOn']; } diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 9a2575a40f4..3b7f625a6ab 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -45,7 +45,7 @@ export type OptionDescriptions = { Subcommand }; -export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web'] as const; +export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web', 'agent-host'] as const; export const OPTIONS: OptionDescriptions> = { 'chat': { @@ -71,6 +71,15 @@ export const OPTIONS: OptionDescriptions> = { 'telemetry-level': { type: 'string' }, } }, + 'agent-host': { + type: 'subcommand', + description: 'Run a server that hosts agents.', + options: { + 'cli-data-dir': { type: 'string', args: 'dir', description: localize('cliDataDir', "Directory where CLI metadata should be stored.") }, + 'disable-telemetry': { type: 'boolean' }, + 'telemetry-level': { type: 'string' }, + } + }, 'tunnel': { type: 'subcommand', description: 'Make the current machine accessible from vscode.dev or other machines through a secure tunnel.', @@ -166,6 +175,7 @@ export const OPTIONS: OptionDescriptions> = { 'inspect-brk-sharedprocess': { type: 'string', allowEmptyValue: true }, 'export-default-configuration': { type: 'string' }, 'export-policy-data': { type: 'string', allowEmptyValue: true }, + 'export-default-keybindings': { type: 'string', allowEmptyValue: true }, 'install-source': { type: 'string' }, 'enable-smoke-test-driver': { type: 'boolean' }, 'skip-sessions-welcome': { type: 'boolean' }, diff --git a/src/vs/platform/keybinding/common/keybindingsRegistry.ts b/src/vs/platform/keybinding/common/keybindingsRegistry.ts index 05660234e61..097d8a73a84 100644 --- a/src/vs/platform/keybinding/common/keybindingsRegistry.ts +++ b/src/vs/platform/keybinding/common/keybindingsRegistry.ts @@ -77,6 +77,7 @@ export interface IKeybindingsRegistry { setExtensionKeybindings(rules: IExtensionKeybindingRule[]): void; registerCommandAndKeybindingRule(desc: ICommandAndKeybindingRule): IDisposable; getDefaultKeybindings(): IKeybindingItem[]; + getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[]; } /** @@ -85,24 +86,23 @@ export interface IKeybindingsRegistry { class KeybindingsRegistryImpl implements IKeybindingsRegistry { private _coreKeybindings: LinkedList; + private _coreKeybindingRules: LinkedList; private _extensionKeybindings: IKeybindingItem[]; private _cachedMergedKeybindings: IKeybindingItem[] | null; constructor() { this._coreKeybindings = new LinkedList(); + this._coreKeybindingRules = new LinkedList(); this._extensionKeybindings = []; this._cachedMergedKeybindings = null; } - /** - * Take current platform into account and reduce to primary & secondary. - */ - private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { - if (OS === OperatingSystem.Windows) { + private static bindToPlatform(kb: IKeybindings, os: OperatingSystem): { primary?: number; secondary?: number[] } { + if (os === OperatingSystem.Windows) { if (kb && kb.win) { return kb.win; } - } else if (OS === OperatingSystem.Macintosh) { + } else if (os === OperatingSystem.Macintosh) { if (kb && kb.mac) { return kb.mac; } @@ -111,10 +111,16 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { return kb.linux; } } - return kb; } + /** + * Take current platform into account and reduce to primary & secondary. + */ + private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { + return KeybindingsRegistryImpl.bindToPlatform(kb, OS); + } + public registerKeybindingRule(rule: IKeybindingRule): IDisposable { const actualKb = KeybindingsRegistryImpl.bindToCurrentPlatform(rule); const result = new DisposableStore(); @@ -135,6 +141,10 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } } } + + const removeRule = this._coreKeybindingRules.push(rule); + result.add(toDisposable(() => { removeRule(); })); + return result; } @@ -193,6 +203,51 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } return this._cachedMergedKeybindings.slice(0); } + + public getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[] { + const result: IKeybindingItem[] = []; + for (const rule of this._coreKeybindingRules) { + const actualKb = KeybindingsRegistryImpl.bindToPlatform(rule, os); + + if (actualKb && actualKb.primary) { + const kk = decodeKeybinding(actualKb.primary, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: 0, + extensionId: null, + isBuiltinExtension: false + }); + } + } + + if (actualKb && Array.isArray(actualKb.secondary)) { + for (let i = 0, len = actualKb.secondary.length; i < len; i++) { + const k = actualKb.secondary[i]; + const kk = decodeKeybinding(k, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: -i - 1, + extensionId: null, + isBuiltinExtension: false + }); + } + } + } + } + + result.sort(sorter); + return result; + } } export const KeybindingsRegistry: IKeybindingsRegistry = new KeybindingsRegistryImpl(); diff --git a/src/vs/platform/url/common/urlGlob.ts b/src/vs/platform/url/common/urlGlob.ts index 9cfd6f530d2..9202ee672cd 100644 --- a/src/vs/platform/url/common/urlGlob.ts +++ b/src/vs/platform/url/common/urlGlob.ts @@ -131,7 +131,10 @@ function doUrlPartMatch( if (!['/', ':'].includes(urlPart[urlOffset])) { options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset + 1, globUrlOffset)); } - options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + // Only skip *. if we're at the start (bare domain) or at a dot boundary + if (urlOffset === 0 || urlPart[urlOffset - 1] === '.') { + options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + } } if (globUrlPart[globUrlOffset] === '*') { diff --git a/src/vs/platform/url/electron-main/electronUrlListener.ts b/src/vs/platform/url/electron-main/electronUrlListener.ts index 49c508e8450..fe9ce0b757d 100644 --- a/src/vs/platform/url/electron-main/electronUrlListener.ts +++ b/src/vs/platform/url/electron-main/electronUrlListener.ts @@ -7,7 +7,7 @@ import { app, Event as ElectronEvent } from 'electron'; import { disposableTimeout } from '../../../base/common/async.js'; import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { isWindows } from '../../../base/common/platform.js'; +import { INodeProcess, isWindows } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILogService } from '../../log/common/log.js'; @@ -50,8 +50,9 @@ export class ElectronURLListener extends Disposable { // Windows: install as protocol handler // Skip in portable mode: the registered command wouldn't preserve - // portable mode settings, causing issues with OAuth flows - if (isWindows && !environmentMainService.isPortable) { + // portable mode settings, causing issues with OAuth flows. + // Skip for embedded apps: protocol handler is registered at install time. + if (isWindows && !environmentMainService.isPortable && !(process as INodeProcess).isEmbeddedApp) { const windowsParameters = environmentMainService.isBuilt ? [] : [`"${environmentMainService.appRoot}"`]; windowsParameters.push('--open-url', '--'); app.setAsDefaultProtocolClient(productService.urlProtocol, process.execPath, windowsParameters); diff --git a/src/vs/platform/url/test/common/urlGlob.test.ts b/src/vs/platform/url/test/common/urlGlob.test.ts index 83534f62ad6..90fee896069 100644 --- a/src/vs/platform/url/test/common/urlGlob.test.ts +++ b/src/vs/platform/url/test/common/urlGlob.test.ts @@ -56,8 +56,29 @@ suite('urlGlob', () => { assert.strictEqual(testUrlMatchesGlob('https://sub.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://sub.domain.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://example.com', 'https://*.example.com'), true); - // *. matches any number of characters before the domain, including other domains - assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), true); + }); + + test('subdomain wildcard must match on dot boundary', () => { + // Should NOT match: no dot boundary before the domain + assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evilmicrosoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-example.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://myexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://notexample.com/path', 'https://*.example.com/path'), false); + + // Should match: proper subdomain with dot boundary + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://a.b.c.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://sub.example.com/path', 'https://*.example.com/path'), true); + }); + + test('subdomain wildcard without scheme must match on dot boundary', () => { + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('http://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', '*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('http://sub.microsoft.com', '*.microsoft.com'), true); }); test('port matching', () => { diff --git a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts index ae70e341c0c..f5d2edb2796 100644 --- a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts +++ b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts @@ -31,7 +31,7 @@ export class SharedWebContentExtractorService implements ISharedWebContentExtrac const content = VSBuffer.wrap(await (response as unknown as { bytes: () => Promise> } /* workaround https://github.com/microsoft/TypeScript/issues/61826 */).bytes()); return content; } catch (err) { - console.log(err); + console.error(err); return undefined; } } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 98fbccd9873..acb2203f9af 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -15,18 +15,21 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle +├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) ├── aiCustomizationDebugPanel.ts # Debug diagnostics panel ├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl -├── customizationHarnessService.ts # Core harness service impl (VS Code harness) +├── customizationHarnessService.ts # Core harness service impl (agent-gated) ├── customizationCreatorService.ts # AI-guided creation flow -├── mcpListWidget.ts # MCP servers section +├── customizationGroupHeaderRenderer.ts # Collapsible group header renderer +├── mcpListWidget.ts # MCP servers section (Extensions + Built-in groups) +├── pluginListWidget.ts # Agent plugins section ├── aiCustomizationIcons.ts # Icons └── media/ └── aiCustomizationManagement.css src/vs/workbench/contrib/chat/common/ -├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter -└── customizationHarnessService.ts # ICustomizationHarnessService + CustomizationHarness enum +├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE +└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers ``` The tree view and overview live in `vs/sessions` (sessions window only): @@ -46,9 +49,10 @@ Sessions-specific overrides: ``` src/vs/sessions/contrib/chat/browser/ ├── aiCustomizationWorkspaceService.ts # Sessions workspace service override -├── customizationHarnessService.ts # Sessions harness service (CLI + Claude harnesses) +├── customizationHarnessService.ts # Sessions harness service (CLI harness only) └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ +├── aiCustomizationShortcutsWidget.ts # Shortcuts widget ├── customizationCounts.ts # Source count utilities (type-aware) └── customizationsToolbar.contribution.ts # Sidebar customization links ``` @@ -59,7 +63,7 @@ The `IAICustomizationWorkspaceService` interface controls per-window behavior: | Property / Method | Core VS Code | Sessions Window | |----------|-------------|----------| -| `managementSections` | All sections except Models | Same minus MCP | +| `managementSections` | All sections except Models | All sections except Models | | `getStorageSourceFilter(type)` | Delegates to `ICustomizationHarnessService` | Delegates to `ICustomizationHarnessService` | | `isSessionsWindow` | `false` | `true` | | `activeProjectRoot` | First workspace folder | Active session worktree | @@ -71,19 +75,34 @@ Storage answers "where did this come from?"; harness answers "who consumes it?". The service is defined in `common/customizationHarnessService.ts` which also provides: - **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor` — parameterized by an `extras` array (the additional storage sources beyond `local`, `user`, `plugin`). Core passes `[PromptsStorage.extension]`; sessions passes `[BUILTIN_STORAGE]`. -- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge so it isn't duplicated. +- **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). +- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. +- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. +- **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. Available harnesses: | Harness | Label | Description | |---------|-------|-------------| -| `vscode` | VS Code | Shows all storage sources (default in core) | +| `vscode` | Local | Shows all storage sources (default in core) | | `cli` | Copilot CLI | Restricts user roots to `~/.copilot`, `~/.claude`, `~/.agents` | -| `claude` | Claude | Restricts user roots to `~/.claude` | +| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | -In core VS Code, all three harnesses are registered; VS Code is the default. -In sessions, `cli` and `claude` harnesses are registered with a toggle bar above the list. +In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. +In sessions, only CLI is registered (single harness, toggle bar hidden). + +### IHarnessDescriptor + +Key properties on the harness descriptor: + +| Property | Purpose | +|----------|--------| +| `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | +| `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | +| `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | +| `sectionOverrides` | Per-section `ISectionOverride` map for button behavior | +| `requiredAgentId` | Agent ID that must be registered for harness to appear | +| `instructionFileFilter` | Filename/path patterns to filter instruction items | ### IStorageSourceFilter @@ -99,9 +118,7 @@ interface IStorageSourceFilter { The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. -**Sessions filter behavior by harness and type:** - -CLI harness: +**Sessions filter behavior (CLI harness):** | Type | sources | includedUserFileRoots | |------|---------|----------------------| @@ -109,15 +126,46 @@ CLI harness: | Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | | Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.copilot, ~/.claude, ~/.agents]` | -Claude harness: +**Core VS Code filter behavior:** + +Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. + +CLI harness (core): | Type | sources | includedUserFileRoots | |------|---------|----------------------| | Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.claude]` | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.copilot, ~/.claude, ~/.agents]` | -**Core VS Code:** All types use `[local, user, extension, plugin]` with no user root filter. +Claude harness (core): + +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local, plugin]` | N/A | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.claude]` | + +Claude additionally applies: +- `hiddenSections: [Prompts, Plugins]` +- `instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md']` +- `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) +- `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension + +### Built-in Extension Grouping (Core VS Code) + +In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". + +This follows the same pattern as the MCP list widget, which determines grouping at the UI layer by inspecting collection sources. The list widget uses `IProductService` to identify the chat extension and sets `groupKey: BUILTIN_STORAGE` on matching items: + +- **Agents**: checks `agent.source.extensionId` against the chat extension ID +- **Skills**: builds a URI→ExtensionIdentifier lookup from `listPromptFiles(PromptsType.skill)`, then checks each skill's URI +- **Prompts**: checks `command.promptPath.extension?.identifier` +- **Instructions/Hooks**: checks `item.extension?.identifier` via `IPromptPath` + +The underlying `storage` remains `PromptsStorage.extension` — the grouping is a UI-level override via `groupKey` that keeps `applyStorageSourceFilter` working with existing storage types while visually distinguishing chat-extension items from third-party extension items. + +`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. ### AgenticPromptsService (Sessions) @@ -149,7 +197,11 @@ Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. T | Skills | `findAgentSkills()` | Parsed skills with frontmatter | | Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | | Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | -| Hooks | `listPromptFiles()` | Raw hook files | +| Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` | + +### Item Badges + +`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. The badge text is also included in search filtering. ### Debug Panel @@ -175,8 +227,9 @@ All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizatio ## Settings -Settings use the `chat.customizationsMenu.` namespace: +Settings use the `chat.customizationsMenu.` and `chat.customizations.` namespaces: | Setting | Default | Description | |---------|---------|-------------| | `chat.customizationsMenu.enabled` | `true` | Show the Chat Customizations editor in the Command Palette | +| `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar | diff --git a/src/vs/sessions/browser/collapsedPartWidgets.ts b/src/vs/sessions/browser/collapsedPartWidgets.ts new file mode 100644 index 00000000000..e5c1789651b --- /dev/null +++ b/src/vs/sessions/browser/collapsedPartWidgets.ts @@ -0,0 +1,313 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/collapsedPanelWidget.css'; +import { $, addDisposableListener, append, EventType } from '../../base/browser/dom.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; +import { IHoverService } from '../../platform/hover/browser/hover.js'; +import { createInstantHoverDelegate } from '../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { localize } from '../../nls.js'; +import { ThemeIcon } from '../../base/common/themables.js'; +import { Codicon } from '../../base/common/codicons.js'; +import { IAgentSessionsService } from '../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionStatus, getAgentChangesSummary, IAgentSession } from '../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ICommandService } from '../../platform/commands/common/commands.js'; +import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; +import { ViewContainerLocation } from '../../workbench/common/views.js'; +import { URI } from '../../base/common/uri.js'; +import { Event } from '../../base/common/event.js'; + +// Duplicated from vs/sessions/contrib/changes/browser/changesView.ts to avoid a layering import. +const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; + +/** + * Collapsed widget shown in the bottom-left corner when the sidebar is hidden. + * Shows session status counts (active, errors, completed) and a new session button. + */ +export class CollapsedSidebarWidget extends Disposable { + + private readonly element: HTMLElement; + private readonly indicatorContainer: HTMLElement; + private readonly indicatorDisposables = this._register(new DisposableStore()); + private readonly hoverDelegate = this._register(createInstantHoverDelegate()); + + constructor( + parent: HTMLElement, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHoverService private readonly hoverService: IHoverService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + this.element = append(parent, $('.collapsed-panel-widget.collapsed-sidebar-widget')); + + // Sidebar toggle button (leftmost) + this._register(this.createSidebarToggleButton()); + + // New session button (next to panel toggle) + this._register(this.createNewSessionButton()); + + // Session status indicators (rightmost) + this.indicatorContainer = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-status')); + + // Listen for session changes + this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); + + // Initial build + this.rebuildIndicators(); + + this.hide(); + } + + private createNewSessionButton(): DisposableStore { + const store = new DisposableStore(); + const btn = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-new-session')); + append(btn, $(ThemeIcon.asCSSSelector(Codicon.newSession))); + + store.add(this.hoverService.setupManagedHover(this.hoverDelegate, btn, localize('newSession', "New Session"))); + + store.add(addDisposableListener(btn, EventType.CLICK, () => { + this.commandService.executeCommand('workbench.action.sessions.newChat'); + })); + + return store; + } + + private createSidebarToggleButton(): DisposableStore { + const store = new DisposableStore(); + const btn = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-panel-toggle')); + let iconElement: HTMLElement | undefined; + + const updateIcon = () => { + const sidebarVisible = this.layoutService.isVisible(Parts.SIDEBAR_PART); + const icon = sidebarVisible ? Codicon.layoutSidebarLeft : Codicon.layoutSidebarLeftOff; + iconElement?.remove(); + iconElement = append(btn, $(ThemeIcon.asCSSSelector(icon))); + }; + + updateIcon(); + + store.add(this.hoverService.setupManagedHover(this.hoverDelegate, btn, localize('toggleSidebar', "Toggle Side Bar"))); + + store.add(addDisposableListener(btn, EventType.CLICK, () => { + this.commandService.executeCommand('workbench.action.agentToggleSidebarVisibility'); + })); + + store.add(this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.SIDEBAR_PART) { + updateIcon(); + } + })); + + return store; + } + + private rebuildIndicators(): void { + this.indicatorDisposables.clear(); + this.indicatorContainer.textContent = ''; + + const sessions = this.agentSessionsService.model.sessions; + const counts = this.countSessionsByStatus(sessions); + + const tooltipParts: string[] = []; + + // In-progress (matches agentSessionsViewer: sessionInProgress) + if (counts.inProgress > 0) { + this.appendStatusSegment(Codicon.sessionInProgress, `${counts.inProgress}`, 'collapsed-sidebar-indicator-active'); + tooltipParts.push(localize('sessionsInProgress', "{0} session(s) in progress", counts.inProgress)); + } + + // Needs input (matches agentSessionsViewer: circleFilled) + if (counts.needsInput > 0) { + this.appendStatusSegment(Codicon.circleFilled, `${counts.needsInput}`, 'collapsed-sidebar-indicator-input'); + tooltipParts.push(localize('sessionsNeedInput', "{0} session(s) need input", counts.needsInput)); + } + + // Failed (matches agentSessionsViewer: error) + if (counts.failed > 0) { + this.appendStatusSegment(Codicon.error, `${counts.failed}`, 'collapsed-sidebar-indicator-error'); + tooltipParts.push(localize('sessionsFailed', "{0} session(s) with errors", counts.failed)); + } + + // Unread (matches agentSessionsViewer: circleFilled with textLink-foreground) + if (counts.unread > 0) { + this.appendStatusSegment(Codicon.circleFilled, `${counts.unread}`, 'collapsed-sidebar-indicator-unread'); + tooltipParts.push(localize('sessionsUnread', "{0} unread session(s)", counts.unread)); + } + + // If no sessions at all + if (sessions.length === 0) { + this.appendStatusSegment(Codicon.commentDiscussion, '0', 'collapsed-sidebar-indicator-empty'); + tooltipParts.push(localize('noSessions', "No sessions")); + } + + if (tooltipParts.length > 0) { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, this.indicatorContainer, tooltipParts.join('\n') + )); + + this.indicatorDisposables.add(addDisposableListener(this.indicatorContainer, EventType.CLICK, () => { + this.layoutService.setPartHidden(false, Parts.SIDEBAR_PART); + })); + } + } + + private appendStatusSegment(icon: ThemeIcon, count: string, className: string): void { + const segment = append(this.indicatorContainer, $(`span.collapsed-sidebar-segment.${className}`)); + append(segment, $(ThemeIcon.asCSSSelector(icon))); + const label = append(segment, $('span.collapsed-sidebar-count')); + label.textContent = count; + } + + private countSessionsByStatus(sessions: IAgentSession[]): { inProgress: number; needsInput: number; failed: number; unread: number } { + let inProgress = 0; + let needsInput = 0; + let failed = 0; + let unread = 0; + + for (const session of sessions) { + if (session.isArchived()) { + continue; + } + switch (session.status) { + case AgentSessionStatus.InProgress: + inProgress++; + break; + case AgentSessionStatus.NeedsInput: + needsInput++; + break; + case AgentSessionStatus.Failed: + failed++; + break; + case AgentSessionStatus.Completed: + if (!session.isRead()) { + unread++; + } + break; + } + } + + return { inProgress, needsInput, failed, unread }; + } + + show(): void { + this.element.classList.remove('collapsed-panel-hidden'); + } + + hide(): void { + this.element.classList.add('collapsed-panel-hidden'); + } +} + +/** + * Widget shown in the titlebar right area showing file change counts + * (files, insertions, deletions) from the active session. + * Always visible — acts as a toggle for the auxiliary bar. + */ +export class CollapsedAuxiliaryBarWidget extends Disposable { + + private readonly element: HTMLElement; + private readonly changesBtn: HTMLElement; + private readonly indicatorDisposables = this._register(new DisposableStore()); + private readonly hoverDelegate = this._register(createInstantHoverDelegate()); + private activeSessionResource: (() => URI | undefined) | undefined; + private readonly activeSessionDisposable = this._register(new MutableDisposable()); + + constructor( + parent: HTMLElement, + windowControlsContainer: HTMLElement | undefined, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHoverService private readonly hoverService: IHoverService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IPaneCompositePartService private readonly paneCompositeService: IPaneCompositePartService, + ) { + super(); + + this.element = $('div.collapsed-panel-widget.collapsed-auxbar-widget'); + + // Insert before the window-controls-container so the widget is not + // hidden behind the WCO on Windows. + if (windowControlsContainer && windowControlsContainer.parentElement === parent) { + parent.insertBefore(this.element, windowControlsContainer); + } else { + append(parent, this.element); + } + + this._register(toDisposable(() => this.element.remove())); + + const indicatorContainer = append(this.element, $('.collapsed-panel-buttons')); + this.changesBtn = append(indicatorContainer, $('.collapsed-panel-button.collapsed-auxbar-indicator')); + + // Click handler lives on the persistent button + this._register(addDisposableListener(this.changesBtn, EventType.CLICK, () => { + const isVisible = !this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + this.layoutService.setPartHidden(!isVisible, Parts.AUXILIARYBAR_PART); + if (isVisible) { + this.paneCompositeService.openPaneComposite(CHANGES_VIEW_CONTAINER_ID, ViewContainerLocation.AuxiliaryBar); + } + })); + + // Listen for session changes to update indicators + this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); + + // Initial build + this.rebuildIndicators(); + } + + /** + * Bind an active-session provider so indicators reflect the currently + * selected session rather than aggregating all sessions. + */ + setActiveSessionProvider(getResource: () => URI | undefined, onDidChange: Event): void { + this.activeSessionResource = getResource; + this.activeSessionDisposable.value = onDidChange(() => this.rebuildIndicators()); + this.rebuildIndicators(); + } + + private rebuildIndicators(): void { + this.indicatorDisposables.clear(); + this.changesBtn.textContent = ''; + + // Get change summary from the active session + const resource = this.activeSessionResource?.(); + const session = resource ? this.agentSessionsService.getSession(resource) : undefined; + const summary = session ? getAgentChangesSummary(session.changes) : undefined; + + // Rebuild inner content: [diff icon] +insertions -deletions + append(this.changesBtn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); + + if (summary && summary.insertions > 0) { + const insLabel = append(this.changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-insertions')); + insLabel.textContent = `+${summary.insertions}`; + } + + if (summary && summary.deletions > 0) { + const delLabel = append(this.changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-deletions')); + delLabel.textContent = `-${summary.deletions}`; + } + + if (summary) { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, this.changesBtn, + localize('changesSummary', "{0} file(s) changed, {1} insertion(s), {2} deletion(s)", summary.files, summary.insertions, summary.deletions) + )); + } else { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, this.changesBtn, + localize('showChanges', "Show Changes") + )); + } + } + + /** + * Update the active visual state of the widget based on + * whether the auxiliary bar is currently visible. + */ + updateActiveState(auxiliaryBarVisible: boolean): void { + this.element.classList.toggle('active', auxiliaryBarVisible); + } +} diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index c9bf983b7c9..8f6701aecb4 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -19,10 +19,6 @@ import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/ import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; // Register Icons -const panelLeftIcon = registerIcon('agent-panel-left', Codicon.layoutSidebarLeft, localize('panelLeft', "Represents a side bar in the left position")); -const panelLeftOffIcon = registerIcon('agent-panel-left-off', Codicon.layoutSidebarLeftOff, localize('panelLeftOff', "Represents a side bar in the left position that is hidden")); -const panelRightIcon = registerIcon('agent-panel-right', Codicon.layoutSidebarRight, localize('panelRight', "Represents a secondary side bar in the right position")); -const panelRightOffIcon = registerIcon('agent-panel-right-off', Codicon.layoutSidebarRightOff, localize('panelRightOff', "Represents a secondary side bar in the right position that is hidden")); const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); class ToggleSidebarVisibilityAction extends Action2 { @@ -34,13 +30,7 @@ class ToggleSidebarVisibilityAction extends Action2 { super({ id: ToggleSidebarVisibilityAction.ID, title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: panelLeftOffIcon, - toggled: { - condition: SideBarVisibleContext, - icon: panelLeftIcon, - title: localize('primary sidebar', "Primary Side Bar"), - mnemonicTitle: localize({ key: 'primary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Primary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), }, @@ -52,10 +42,10 @@ class ToggleSidebarVisibilityAction extends Action2 { }, menu: [ { - id: Menus.TitleBarLeftLayout, + id: Menus.SidebarTitle, group: 'navigation', - order: 0, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) + order: 100, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SideBarVisibleContext, SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, @@ -90,13 +80,7 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { super({ id: ToggleSecondarySidebarVisibilityAction.ID, title: localize2('toggleSecondarySidebar', 'Toggle Secondary Side Bar Visibility'), - icon: panelRightOffIcon, - toggled: { - condition: AuxiliaryBarVisibleContext, - icon: panelRightIcon, - title: localize('secondary sidebar', "Secondary Side Bar"), - mnemonicTitle: localize({ key: 'secondary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Secondary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSecondarySidebar', 'Open/Show and Close/Hide Secondary Side Bar'), }, @@ -104,10 +88,10 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { f1: true, menu: [ { - id: Menus.TitleBarRightLayout, + id: Menus.AuxiliaryBarTitle, group: 'navigation', - order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) + order: 100, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), AuxiliaryBarVisibleContext, SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, diff --git a/src/vs/sessions/browser/media/collapsedPanelWidget.css b/src/vs/sessions/browser/media/collapsedPanelWidget.css new file mode 100644 index 00000000000..b7d2d4e78c4 --- /dev/null +++ b/src/vs/sessions/browser/media/collapsedPanelWidget.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. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Collapsed Part Widgets (shared, inline in titlebar) ---- */ + +.agent-sessions-workbench .collapsed-panel-widget { + display: flex; + flex-direction: row; + align-items: center; + gap: 2px; + padding: 0 4px; + height: 100%; + position: relative; + z-index: 2500; /* Above titlebar toolbar actions so widgets remain clickable */ + -webkit-app-region: no-drag; +} + +.agent-sessions-workbench .collapsed-panel-widget.collapsed-panel-hidden { + display: none; +} + +/* ---- Sidebar widget (in titlebar-left) ---- */ + +.agent-sessions-workbench .collapsed-sidebar-widget { + order: 10; + padding-left: 8px; +} + +/* ---- Auxiliary Bar widget (in titlebar-right) ---- */ + +.agent-sessions-workbench .collapsed-auxbar-widget { + order: 1; +} + +.agent-sessions-workbench .collapsed-auxbar-widget.active .collapsed-panel-button { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); +} + +/* ---- Buttons (match titlebar action-item sizing) ---- */ + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: 0; + height: 100%; +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button { + display: flex; + align-items: center; + justify-content: center; + height: 22px; + padding: 0 4px; + border-radius: var(--vscode-cornerRadius-medium); + cursor: pointer; + color: inherit; + gap: 3px; +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button:active { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button .codicon { + font-size: 16px; + color: inherit; +} + +/* ---- Consolidated session status button ---- */ + +.agent-sessions-workbench .collapsed-panel-button.collapsed-sidebar-status { + gap: 6px; +} + +.agent-sessions-workbench .collapsed-sidebar-segment { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.agent-sessions-workbench .collapsed-panel-button .collapsed-sidebar-segment .codicon { + font-size: 14px; +} + +/* ---- Sidebar indicators ---- */ + +.agent-sessions-workbench .collapsed-sidebar-count, +.agent-sessions-workbench .collapsed-auxbar-count { + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 16px; + color: inherit; +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-active .codicon { + color: var(--vscode-textLink-foreground); +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-error { + color: var(--vscode-errorForeground); +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input { + color: var(--vscode-list-warningForeground); +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input .codicon { + animation: collapsed-sidebar-needs-input-pulse 2s ease-in-out infinite; +} + +@keyframes collapsed-sidebar-needs-input-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input .codicon { + animation: none; + } +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-unread { + color: var(--vscode-textLink-foreground); +} + +/* ---- Panel toggle button ---- */ + +.agent-sessions-workbench .collapsed-sidebar-panel-toggle { + opacity: 0.7; +} + +.agent-sessions-workbench .collapsed-sidebar-panel-toggle:hover { + opacity: 1; +} + +/* ---- Auxiliary bar indicators ---- */ + +.agent-sessions-workbench .collapsed-auxbar-insertions { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.agent-sessions-workbench .collapsed-auxbar-deletions { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.agent-sessions-workbench span.collapsed-auxbar-count.collapsed-auxbar-insertions, +.agent-sessions-workbench span.collapsed-auxbar-count.collapsed-auxbar-deletions { + font-weight: 600; +} diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 932170f362b..ca601ca71db 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -22,13 +22,15 @@ .agent-sessions-workbench .part.sidebar { background: var(--vscode-sideBar-background); border-right: 1px solid var(--vscode-sideBar-border, transparent); + animation: sessions-card-enter-left 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; } .agent-sessions-workbench .part.auxiliarybar { - margin: 16px 16px 18px 0; + margin: 0 16px 2px 0; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + animation: sessions-card-enter-right 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; } .agent-sessions-workbench .part.panel { @@ -36,6 +38,49 @@ background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + animation: sessions-card-enter-up 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; +} + +/* Card entrance animations */ +@keyframes sessions-card-enter-left { + from { + opacity: 0; + transform: translateX(-12px) scale(0.97); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes sessions-card-enter-right { + from { + opacity: 0; + transform: translateX(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes sessions-card-enter-up { + from { + opacity: 0; + transform: translateY(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .part.sidebar, + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel { + animation: none; + } } /* Grid background matches the chat bar / sidebar background */ diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 4d8f2825a55..fcc12cec6fd 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -49,7 +49,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { /** Visual margin values for the card-like appearance */ static readonly MARGIN_TOP = 16; - static readonly MARGIN_BOTTOM = 18; + static readonly MARGIN_BOTTOM = 2; static readonly MARGIN_RIGHT = 16; // Action ID for run script - defined here to avoid layering issues @@ -83,7 +83,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return undefined; } - return Math.max(width, 340); + return Math.max(width, 380); } readonly priority = LayoutPriority.Low; diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 7755e7c95b0..644cbbf6c6a 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -54,13 +54,37 @@ height: 100%; } +/* Layout actions toolbar appears after the diff widget */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container { + order: 2; + /* Always render so we can animate in/out instead of display:none */ + display: flex !important; + align-items: center; + overflow: hidden; + width: 0; + opacity: 0; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container:not(.has-no-actions) { + /* TODO: Hardcoded to separator (9px) + single action button (28px). + Update if more actions are added to TitleBarRightLayout. */ + width: 37px; + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container { + transition: width 0.15s ease-out, opacity 0.15s ease-out; + } +} + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container:not(.has-no-actions) { display: flex; align-items: center; } -/* Separator between session actions and layout actions toolbar */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-layout-actions-container:not(.has-no-actions)::before { +/* Separator before layout actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container:not(.has-no-actions)::before { content: ''; width: 1px; height: 16px; @@ -68,6 +92,12 @@ background-color: var(--vscode-disabledForeground); } +/* Toggled action buttons in session actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.checked { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); +} + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .codicon { color: inherit; } diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 18c2d2867f0..736b483b5d8 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -81,6 +81,10 @@ export class TitlebarPart extends Part implements ITitlebarPart { private centerContent!: HTMLElement; private rightContent!: HTMLElement; + get leftContainer(): HTMLElement { return this.leftContent; } + get rightContainer(): HTMLElement { return this.rightContent; } + get rightWindowControlsContainer(): HTMLElement | undefined { return this.windowControlsContainer; } + private readonly titleBarStyle: TitlebarStyle; private isInactive: boolean = false; @@ -207,6 +211,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { const rightToolbarContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-layout-actions-container')); this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRightLayout, { contextMenu: Menus.TitleBarContext, + hiddenItemStrategy: HiddenItemStrategy.NoHide, telemetrySource: 'titlePart.right', toolbarOptions: { primaryGroup: () => true }, })); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index ca045ac8260..2436595fd59 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -5,6 +5,7 @@ import '../../workbench/browser/style.js'; import './media/style.css'; +import { CollapsedSidebarWidget, CollapsedAuxiliaryBarWidget } from './collapsedPartWidgets.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; @@ -22,7 +23,7 @@ import { IEditorService } from '../../workbench/services/editor/common/editorSer import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../workbench/common/views.js'; import { ILogService } from '../../platform/log/common/log.js'; -import { IInstantiationService, ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor, createDecorator } from '../../platform/instantiation/common/instantiation.js'; import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; import { mainWindow, CodeWindow } from '../../base/browser/window.js'; import { coalesce } from '../../base/common/arrays.js'; @@ -60,7 +61,19 @@ import { NotificationsToasts } from '../../workbench/browser/parts/notifications import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; -import { TitleService } from './parts/titlebarPart.js'; +import { TitleService, TitlebarPart } from './parts/titlebarPart.js'; +import { URI } from '../../base/common/uri.js'; +import { IObservable } from '../../base/common/observable.js'; + +/** + * Minimal typing for ISessionsManagementService resolved dynamically to avoid + * a layering import from vs/sessions/contrib/. + */ +interface IMinimalSessionsManagementService { + getActiveSession(): { resource: URI } | undefined; + readonly activeSession: IObservable; +} +const _ISessionsManagementService = createDecorator('sessionsManagementService'); //#region Workbench Options @@ -231,6 +244,9 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { private chatBarPartView!: ISerializableView; + private collapsedSidebarWidget: CollapsedSidebarWidget | undefined; + private collapsedAuxiliaryBarWidget: CollapsedAuxiliaryBarWidget | undefined; + private readonly partVisibility: IPartVisibilityState = { sidebar: true, auxiliaryBar: false, @@ -369,6 +385,35 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Layout this.layout(); + // Collapsed Sidebar Widget (shown when sidebar is hidden) + const titlebarPart = this.getPart(Parts.TITLEBAR_PART) as TitlebarPart; + this.collapsedSidebarWidget = this._register(instantiationService.createInstance(CollapsedSidebarWidget, titlebarPart.leftContainer)); + if (!this.partVisibility.sidebar) { + this.collapsedSidebarWidget.show(); + } + + // Auxiliary bar changes widget (always visible, acts as a toggle) + this.collapsedAuxiliaryBarWidget = this._register(instantiationService.createInstance(CollapsedAuxiliaryBarWidget, titlebarPart.rightContainer, titlebarPart.rightWindowControlsContainer)); + this.collapsedAuxiliaryBarWidget.updateActiveState(this.partVisibility.auxiliaryBar); + + // Wire active session provider after restore, when ISessionsManagementService is available. + // Resolved via createDecorator to avoid a layering import from vs/sessions/contrib/. + // Note: whenRestored is a deferred promise that resolves inside restore() below. + const auxWidget = this.collapsedAuxiliaryBarWidget; + this.whenRestored.then(() => { + instantiationService.invokeFunction(accessor => { + try { + const svc = accessor.get(_ISessionsManagementService); + auxWidget.setActiveSessionProvider( + () => svc.getActiveSession()?.resource, + Event.fromObservableLight(svc.activeSession) + ); + } catch { + // Service not registered — indicators will remain empty + } + }); + }); + // Restore this.restore(lifecycleService); }); @@ -783,7 +828,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Default sizes const sideBarSize = 300; - const auxiliaryBarSize = 340; + const auxiliaryBarSize = 380; const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; @@ -1060,6 +1105,13 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !hidden, ); + // Toggle collapsed sidebar widget + if (hidden) { + this.collapsedSidebarWidget?.show(); + } else { + this.collapsedSidebarWidget?.hide(); + } + // If sidebar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); @@ -1089,6 +1141,9 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !hidden, ); + // Update collapsed auxiliary bar widget active state + this.collapsedAuxiliaryBarWidget?.updateActiveState(!hidden); + // If auxiliary bar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 7ff02612cc1..bcae4ce6990 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -6,17 +6,78 @@ import './agentFeedbackEditorInputContribution.js'; import './agentFeedbackEditorWidgetContribution.js'; import './agentFeedbackOverviewRulerContribution.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; import { AgentFeedbackEditorOverlay } from './agentFeedbackEditorOverlay.js'; -import { registerAgentFeedbackEditorActions } from './agentFeedbackEditorActions.js'; +import { hasActiveSessionAgentFeedback, registerAgentFeedbackEditorActions, submitActiveSessionFeedbackActionId } from './agentFeedbackEditorActions.js'; import { IChatAttachmentWidgetRegistry } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +/** + * Sets the `hasActiveSessionAgentFeedback` context key to true when the + * currently active session has pending agent feedback items. + */ +class ActiveSessionFeedbackContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFeedbackContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + const contextKey = hasActiveSessionAgentFeedback.bindTo(contextKeyService); + const menuRegistration = this._register(new MutableDisposable()); + + const feedbackChanged = observableFromEvent( + this, + agentFeedbackService.onDidChangeFeedback, + e => e, + ); + + this._register(autorun(reader => { + feedbackChanged.read(reader); + const activeSession = sessionManagementService.activeSession.read(reader); + menuRegistration.clear(); + if (!activeSession) { + contextKey.set(false); + return; + } + const feedback = agentFeedbackService.getFeedback(activeSession.resource); + const count = feedback.length; + contextKey.set(count > 0); + + if (count > 0) { + menuRegistration.value = MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionApplySubmenu, { + command: { + id: submitActiveSessionFeedbackActionId, + icon: Codicon.comment, + title: localize('agentFeedback.submitFeedbackCount', "Submit Feedback ({0})", count), + }, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionAgentFeedback), + }); + } + })); + } +} + +registerWorkbenchContribution2(ActiveSessionFeedbackContextContribution.ID, ActiveSessionFeedbackContextContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index baa0d345790..f7808619d75 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -24,6 +24,7 @@ import { IChatEditingService } from '../../../../workbench/contrib/chat/common/e import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; import { getSessionEditorComments } from './sessionEditorComments.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; @@ -32,6 +33,8 @@ export const clearAllFeedbackActionId = 'agentFeedbackEditor.action.clearAll'; export const navigationBearingFakeActionId = 'agentFeedbackEditor.navigation.bearings'; export const hasSessionEditorComments = new RawContextKey('agentFeedbackEditor.hasSessionComments', false); export const hasSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasAgentFeedback', false); +export const hasActiveSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasActiveSessionAgentFeedback', false); +export const submitActiveSessionFeedbackActionId = 'agentFeedbackEditor.action.submitActiveSession'; abstract class AgentFeedbackEditorAction extends Action2 { @@ -190,8 +193,66 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { } } +class SubmitActiveSessionFeedbackAction extends Action2 { + + static readonly ID = submitActiveSessionFeedbackActionId; + + constructor() { + super({ + id: SubmitActiveSessionFeedbackAction.ID, + title: localize2('agentFeedback.submitFeedback', 'Submit Feedback'), + icon: Codicon.comment, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionAgentFeedback), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const chatWidgetService = accessor.get(IChatWidgetService); + const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const feedbackItems = agentFeedbackService.getFeedback(sessionResource); + if (feedbackItems.length === 0) { + return; + } + + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); + return; + } + + // Close all editors belonging to the session resource + const editorsToClose: IEditorIdentifier[] = []; + for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { + const candidates = getActiveResourceCandidates(editor); + const belongsToSession = candidates.some(uri => + isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) + ); + if (belongsToSession) { + editorsToClose.push({ editor, groupId }); + } + } + if (editorsToClose.length) { + await editorService.closeEditors(editorsToClose); + } + + await widget.acceptInput('act on feedback'); + } +} + export function registerAgentFeedbackEditorActions(): void { registerAction2(SubmitFeedbackAction); + registerAction2(SubmitActiveSessionFeedbackAction); registerAction2(class extends NavigateFeedbackAction { constructor() { super(false); } }); registerAction2(class extends NavigateFeedbackAction { constructor() { super(true); } }); registerAction2(ClearAllFeedbackAction); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 9da044d818d..ceb8a6032d1 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts @@ -10,9 +10,10 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; -import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, changesContainerTitle, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import './changesViewActions.js'; -import { ToggleChangesViewContribution } from './toggleChangesView.js'; +import './fixCIChecksAction.js'; +import { ChangesViewController } from './changesViewController.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -20,7 +21,7 @@ const viewContainersRegistry = Registry.as(ViewContaine const changesViewContainer = viewContainersRegistry.registerViewContainer({ id: CHANGES_VIEW_CONTAINER_ID, - title: localize2('changes', 'Changes'), + title: changesContainerTitle, ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), icon: changesViewIcon, order: 10, @@ -42,4 +43,4 @@ viewsRegistry.registerViews([{ windowVisibility: WindowVisibility.Sessions }], changesViewContainer); -registerWorkbenchContribution2(ToggleChangesViewContribution.ID, ToggleChangesViewContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChangesViewController.ID, ChangesViewController, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index e4f6e029bcf..cb1c159f5cd 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -12,18 +12,19 @@ import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; -import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; +import { ILocalizedString } from '../../../../platform/action/common/action.js'; import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId, Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -48,21 +49,20 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { chatEditingWidgetFileStateContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; -import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionWorkspace.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { CIStatusWidget } from './ciStatusWidget.js'; +import { arrayEqualsC } from '../../../../base/common/equals.js'; const $ = dom.$; @@ -70,6 +70,16 @@ const $ = dom.$; export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; + +// Dynamic title for the Changes view container tab. +// Uses a getter so that ViewContainerModel.updateContainerInfo() picks up +// the latest value each time it re-reads viewContainer.title.value. +let _changesContainerTitleValue = localize('changes', 'Changes'); +export const changesContainerTitle: ILocalizedString = { + original: 'Changes', + get value() { return _changesContainerTitleValue; } +}; + const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; // --- View Mode @@ -91,6 +101,8 @@ const enum ChangesVersionMode { const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); const isMergeBaseBranchProtectedContextKey = new RawContextKey('sessions.isMergeBaseBranchProtected', false); const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); +const hasIncomingChangesContextKey = new RawContextKey('sessions.hasIncomingChanges', false); +const hasOutgoingChangesContextKey = new RawContextKey('sessions.hasOutgoingChanges', false); // --- List Item @@ -202,6 +214,101 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement; + readonly activeSessionResourceObs: IObservable; + readonly activeSessionRepositoryObs: IObservableWithChange; + readonly activeSessionChangesObs: IObservable; + + readonly versionModeObs: ISettableObservable; + setVersionMode(mode: ChangesVersionMode): void { + if (this.versionModeObs.get() === mode) { + return; + } + this.versionModeObs.set(mode, undefined); + } + + readonly viewModeObs: ISettableObservable; + setViewMode(mode: ChangesViewMode): void { + if (this.viewModeObs.get() === mode) { + return; + } + this.viewModeObs.set(mode, undefined); + this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); + } + + constructor( + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IGitService private readonly gitService: IGitService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + // Active session changes + this.sessionsChangedSignal = observableSignalFromEvent(this, + this.agentSessionsService.model.onDidChangeSessions); + + // Active session resource + this.activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.resource; + }); + + // Active session changes + this.activeSessionChangesObs = derivedOpts({ + equalsFn: arrayEqualsC() + }, reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return Iterable.empty(); + } + + this.sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + return model?.changes instanceof Array ? model.changes : Iterable.empty(); + }); + + // Active session repository + const activeSessionRepositoryPromiseObs = derived(reader => { + const activeSessionResource = this.activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return constObservable(undefined); + } + + const activeSession = this.sessionManagementService.getActiveSession(); + if (!activeSession?.worktree) { + return constObservable(undefined); + } + + return new ObservablePromise(this.gitService.openRepository(activeSession.worktree)).resolvedValue; + }); + + this.activeSessionRepositoryObs = derived(reader => { + const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); + if (activeSessionRepositoryPromise === undefined) { + return undefined; + } + + return activeSessionRepositoryPromise.read(reader); + }); + + // Version mode + this.versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); + + this._register(runOnChange(this.activeSessionResourceObs, () => { + this.setVersionMode(ChangesVersionMode.AllChanges); + })); + + // View mode + const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); + const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; + this.viewModeObs = observableValue(this, initialMode); + } +} + // --- View Pane export class ChangesViewPane extends ViewPane { @@ -224,44 +331,7 @@ export class ChangesViewPane extends ViewPane { private currentBodyHeight = 0; private currentBodyWidth = 0; - // View mode (list vs tree) - private readonly viewModeObs: ReturnType>; - private readonly viewModeContextKey: IContextKey; - - get viewMode(): ChangesViewMode { return this.viewModeObs.get(); } - set viewMode(mode: ChangesViewMode) { - if (this.viewModeObs.get() === mode) { - return; - } - this.viewModeObs.set(mode, undefined); - this.viewModeContextKey.set(mode); - this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); - } - - // Version mode (all changes, last turn, uncommitted) - private readonly versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); - private readonly versionModeContextKey: IContextKey; - - setVersionMode(mode: ChangesVersionMode): void { - if (this.versionModeObs.get() === mode) { - return; - } - this.versionModeObs.set(mode, undefined); - this.versionModeContextKey.set(mode); - } - - // Track the active session used by this view - private readonly activeSession: IObservableWithChange; - private readonly activeSessionFileCountObs: IObservableWithChange; - private readonly activeSessionHasChangesObs: IObservableWithChange; - private readonly activeSessionRepositoryObs: IObservableWithChange; - - get activeSessionHasChanges(): IObservable { - return this.activeSessionHasChangesObs; - } - - // Badge for file count - private readonly badgeDisposable = this._register(new MutableDisposable()); + readonly viewModel: ChangesViewModel; constructor( options: IViewPaneOptions, @@ -274,123 +344,47 @@ export class ChangesViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, @IEditorService private readonly editorService: IEditorService, - @IActivityService private readonly activityService: IActivityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, - @IStorageService private readonly storageService: IStorageService, @ICodeReviewService private readonly codeReviewService: ICodeReviewService, - @IGitService private readonly gitService: IGitService, @IGitHubService private readonly gitHubService: IGitHubService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - // View mode - const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); - const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; - this.viewModeObs = observableValue(this, initialMode); - this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); - this.viewModeContextKey.set(initialMode); + this.viewModel = this.instantiationService.createInstance(ChangesViewModel); + this._register(this.viewModel); // Version mode - this.versionModeContextKey = changesVersionModeContextKey.bindTo(contextKeyService); - this.versionModeContextKey.set(ChangesVersionMode.AllChanges); - - // Track active session from sessions management service - this.activeSession = derivedOpts({ - equalsFn: (a, b) => isEqual(a?.resource, b?.resource), - }, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - if (!activeSession?.resource) { - return undefined; - } - - return activeSession; - }).recomputeInitiallyAndOnChange(this._store); - - // Track active session repository changes - const activeSessionRepositoryPromiseObs = derived(reader => { - const activeSessionWorktree = this.activeSession.read(reader)?.worktree; - if (!activeSessionWorktree) { - return constObservable(undefined); - } - - return new ObservablePromise(this.gitService.openRepository(activeSessionWorktree)).resolvedValue; - }); - - this.activeSessionRepositoryObs = derived(reader => { - const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); - if (activeSessionRepositoryPromise === undefined) { - return undefined; - } - - return activeSessionRepositoryPromise.read(reader); - }); - - this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); - this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); - - // Set chatSessionType on the view's context key service so ViewTitle - // menu items can use it in their `when` clauses. Update reactively - // when the active session changes. - const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this._register(autorun(reader => { - const activeSession = this.activeSession.read(reader); - viewSessionTypeKey.set(activeSession?.providerType ?? ''); + this._register(bindContextKey(changesVersionModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.versionModeObs.read(reader); })); - } - private createActiveSessionFileCountObservable(): IObservableWithChange { - const activeSessionResource = this.activeSession.map(a => a?.resource); + // View mode + this._register(bindContextKey(changesViewModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.viewModeObs.read(reader); + })); - const sessionsChangedSignal = observableFromEvent( - this, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); + // Set chatSessionType on the view's context key service so ViewTitle menu items + // can use it in their `when` clauses. Update reactively when the active session + // changes. + this._register(bindContextKey(ChatContextKeys.agentSessionType, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.providerType ?? ''; + })); - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); + // Fallback title update: when the view is not visible (renderDisposables + // cleared), keep the container title in sync with the raw session changes + // so the tab still shows a count when the user switches sessions. + this._register(autorun(reader => { + if (this.isBodyVisible()) { + // onVisible() drives the title from topLevelStats while visible + return; } - - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); - - return derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return 0; - } - - let editingSessionCount = 0; - if (activeSession.providerType !== AgentSessionProviders.Background) { - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - editingSessionCount = session ? session.entries.read(reader).length : 0; - } - - const sessionFiles = [...sessionFileChangesObs.read(reader)]; - const sessionFilesCount = sessionFiles.length; - - return editingSessionCount + sessionFilesCount; - }).recomputeInitiallyAndOnChange(this._store); - } - - private updateBadge(fileCount: number): void { - if (fileCount > 0) { - const message = fileCount === 1 - ? localize('changesView.oneFileChanged', '1 file changed') - : localize('changesView.filesChanged', '{0} files changed', fileCount); - this.badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(fileCount, () => message) }); - } else { - this.badgeDisposable.clear(); - } + const changes = this.viewModel.activeSessionChangesObs.read(reader); + this.updateContainerTitle(changes.length); + })); } protected override renderBody(container: HTMLElement): void { @@ -444,82 +438,33 @@ export class ChangesViewPane extends ViewPane { } } + private updateContainerTitle(fileCount: number): void { + let nextTitle: string; + if (fileCount === 0) { + nextTitle = localize('changes', 'Changes'); + } else if (fileCount === 1) { + nextTitle = localize('changesView.titleWithCountOne', '1 Change'); + } else { + nextTitle = localize('changesView.titleWithCount', '{0} Changes', fileCount); + } + + if (nextTitle === _changesContainerTitleValue) { + return; + } + + _changesContainerTitleValue = nextTitle; + const viewContainer = this.viewDescriptorService.getViewContainerById(CHANGES_VIEW_CONTAINER_ID); + if (viewContainer) { + this.viewDescriptorService.getViewContainerModel(viewContainer).refreshContainerInfo(); + } + } + private onVisible(): void { this.renderDisposables.clear(); - const activeSessionResource = this.activeSession.map(a => a?.resource); - - // Create observable for the active editing session - // Note: We must read editingSessionsObs to establish a reactive dependency, - // so that the view updates when a new editing session is added (e.g., cloud sessions) - const activeEditingSessionObs = derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return undefined; - } - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - return sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - }); - - // Create observable for edit session entries from the ACTIVE session only (local editing sessions) - const editSessionEntriesObs = derived(reader => { - const activeSession = this.activeSession.read(reader); - - // Background chat sessions render the working set based on the session files, not the editing session - if (activeSession?.providerType === AgentSessionProviders.Background) { - return []; - } - - const session = activeEditingSessionObs.read(reader); - if (!session) { - return []; - } - - const entries = session.entries.read(reader); - const items: IChangesFileItem[] = []; - - for (const entry of entries) { - const isDeletion = entry.isDeletion ?? false; - const linesAdded = entry.linesAdded?.read(reader) ?? 0; - const linesRemoved = entry.linesRemoved?.read(reader) ?? 0; - - items.push({ - type: 'file', - uri: entry.modifiedURI, - originalUri: entry.originalURI, - state: entry.state.read(reader), - isDeletion, - changeType: isDeletion ? 'deleted' : 'modified', - linesAdded, - linesRemoved, - reviewCommentCount: 0, - }); - } - - return items; - }); - - // Signal observable that triggers when sessions data changes - const sessionsChangedSignal = observableFromEvent( - this.renderDisposables, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); - - // Observable for session file changes from agentSessionsService (cloud/background sessions) - // Reactive to both activeSession changes AND session data changes - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); - } - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); const reviewCommentCountByFileObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - const sessionChanges = [...sessionFileChangesObs.read(reader)]; + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; if (!sessionResource) { return new Map(); @@ -534,11 +479,11 @@ export class ChangesViewPane extends ViewPane { } } - if (sessionChanges.length === 0) { + if (changes.length === 0) { return result; } - const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewFiles = getCodeReviewFilesFromSessionChanges(changes as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); const reviewVersion = getCodeReviewVersion(reviewFiles); const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); @@ -557,8 +502,9 @@ export class ChangesViewPane extends ViewPane { // Convert session file changes to list items (cloud/background sessions) const sessionFilesObs = derived(reader => { const reviewCommentCountByFile = reviewCommentCountByFileObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; - return [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + return changes.map((entry): IChangesFileItem => { const isDeletion = entry.modifiedUri === undefined; const isAddition = entry.originalUri === undefined; const uri = isIChatSessionFileChange2(entry) @@ -578,21 +524,18 @@ export class ChangesViewPane extends ViewPane { }); }); - // Create observable for last turn changes using diffBetweenWithStats - // Reactively computes the diff between HEAD^ and HEAD. Memoize the diff observable so - // that we only recompute it when the HEAD commit id actually changes. const headCommitObs = derived(reader => { - const repository = this.activeSessionRepositoryObs.read(reader); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); return repository?.state.read(reader)?.HEAD?.commit; }); const lastCheckpointRefObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); if (!sessionResource) { return undefined; } - sessionsChangedSignal.read(reader); + this.viewModel.sessionsChangedSignal.read(reader); const model = this.agentSessionsService.getSession(sessionResource); return model?.metadata?.lastCheckpointRef as string | undefined; @@ -615,7 +558,7 @@ export class ChangesViewPane extends ViewPane { }); const lastTurnChangesObs = derived(reader => { - const repository = this.activeSessionRepositoryObs.read(reader); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); const headCommit = headCommitObs.read(reader); if (!repository || !headCommit) { @@ -633,10 +576,9 @@ export class ChangesViewPane extends ViewPane { // Combine both entry sources for display const combinedEntriesObs = derived(reader => { const headCommit = headCommitObs.read(reader); - const versionMode = this.versionModeObs.read(reader); - const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); const lastTurnDiffChanges = lastTurnChangesObs.read(reader).read(reader); + const versionMode = this.viewModel.versionModeObs.read(reader); let sourceEntries: IChangesFileItem[]; if (versionMode === ChangesVersionMode.LastTurn) { @@ -679,7 +621,7 @@ export class ChangesViewPane extends ViewPane { } satisfies IChangesFileItem; }); } else { - sourceEntries = [...editEntries, ...sessionFiles]; + sourceEntries = [...sessionFiles]; } const resources = new Set(); @@ -695,8 +637,6 @@ export class ChangesViewPane extends ViewPane { // Calculate stats from combined entries const topLevelStats = derived(reader => { - const editEntries = editSessionEntriesObs.read(reader); - const sessionFiles = sessionFilesObs.read(reader); const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -706,78 +646,59 @@ export class ChangesViewPane extends ViewPane { removed += entry.linesRemoved; } - const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; - - return { files, added, removed, isSessionMenu }; + return { files: entries.length, added, removed }; }); // Setup context keys and actions toolbar if (this.actionsContainer) { dom.clearNode(this.actionsContainer); - const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); - - // Set the chat session type context key reactively so that menu items with - // `chatSessionType == copilotcli` (e.g. Create Pull Request) are shown - const chatSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this.renderDisposables.add(autorun(reader => { - const activeSession = this.activeSession.read(reader); - chatSessionTypeKey.set(activeSession?.providerType ?? ''); - })); - - // Bind required context keys for the menu buttons - this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, this.scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); - })); - - this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, this.scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.length > 0; - })); - - const hasAgentSessionChangesObs = derived(reader => { + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { const { files } = topLevelStats.read(reader); return files > 0; - }); + })); - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, r => hasAgentSessionChangesObs.read(r))); - - const isMergeBaseBranchProtectedObs = derived(reader => { - const activeSession = this.activeSession.read(reader); + this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); return activeSession?.worktreeBaseBranchProtected === true; - }); + })); - this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, r => isMergeBaseBranchProtectedObs.read(r))); - - const hasOpenPullRequestObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); + this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, reader => { + this.viewModel.sessionsChangedSignal.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); if (!sessionResource) { return false; } - sessionsChangedSignal.read(reader); - const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; - return !!metadata?.pullRequestUrl; + return metadata?.pullRequestUrl !== undefined; + })); + + this.renderDisposables.add(bindContextKey(hasIncomingChangesContextKey, this.scopedContextKeyService, reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return (repositoryState?.HEAD?.behind ?? 0) > 0; + })); + + const outgoingChangesObs = derived(reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return repositoryState?.HEAD?.ahead ?? 0; }); - this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, r => hasOpenPullRequestObs.read(r))); + this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { + const outgoingChanges = outgoingChangesObs.read(reader); + return outgoingChanges > 0; + })); + + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); + const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); + this.renderDisposables.add(scopedInstantiationService); this.renderDisposables.add(autorun(reader => { - const { isSessionMenu, added, removed } = topLevelStats.read(reader); - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); // Re-evaluate when session metadata changes (e.g. pullRequestUrl) - const menuId = isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar; + const { added, removed } = topLevelStats.read(reader); + const outgoingChanges = outgoingChangesObs.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); // Read code review state to update the button label dynamically let reviewCommentCount: number | undefined; @@ -807,11 +728,11 @@ export class ChangesViewPane extends ViewPane { reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, - menuId, + MenuId.ChatEditingSessionChangesToolbar, { telemetrySource: 'changesView', - disableWhileRunning: isSessionMenu, - menuOptions: isSessionMenu && sessionResource + disableWhileRunning: true, + menuOptions: sessionResource ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, buttonConfigProvider: (action) => { @@ -837,6 +758,13 @@ export class ChangesViewPane extends ViewPane { if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR') { return { showIcon: true, showLabel: true, isSecondary: false }; } + if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR') { + const customLabel = outgoingChanges > 0 + ? localize('updatePRWithOutgoingChanges', 'Update Pull Request {0}↑', outgoingChanges) + : localize('updatePR', 'Update Pull Request'); + + return { customLabel, showIcon: true, showLabel: true, isSecondary: false }; + } if (action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR') { return { showIcon: true, showLabel: false, isSecondary: true }; } @@ -860,12 +788,12 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(!hasEntries, this.welcomeContainer!); })); - // Update badge when file count changes + // Update inline title count from the same stats the tree uses this.renderDisposables.add(autorun(reader => { - this.updateBadge(topLevelStats.read(reader).files); + this.updateContainerTitle(topLevelStats.read(reader).files); })); - // Update summary text (line counts only, file count is shown in badge) + // Update summary text (line counts only) if (this.summaryContainer) { dom.clearNode(this.summaryContainer); @@ -925,7 +853,7 @@ export class ChangesViewPane extends ViewPane { }, compressionEnabled: true, twistieAdditionalCssClass: (e: unknown) => { - return this.viewMode === ChangesViewMode.List + return this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 'force-no-twistie' : undefined; }, @@ -1020,7 +948,7 @@ export class ChangesViewPane extends ViewPane { // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const entries = combinedEntriesObs.read(reader); - const viewMode = this.viewModeObs.read(reader); + const viewMode = this.viewModel.viewModeObs.read(reader); if (!this.tree) { return; @@ -1129,6 +1057,7 @@ export class ChangesViewPane extends ViewPane { } override dispose(): void { + this.updateContainerTitle(0); this.tree?.dispose(); this.tree = undefined; super.dispose(); @@ -1367,7 +1296,7 @@ class SetChangesListViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.List; + view.viewModel.setViewMode(ChangesViewMode.List); } } @@ -1390,7 +1319,7 @@ class SetChangesTreeViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.Tree; + view.viewModel.setViewMode(ChangesViewMode.Tree); } } @@ -1426,7 +1355,7 @@ class AllChangesAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); - view?.setVersionMode(ChangesVersionMode.AllChanges); + view?.viewModel.setVersionMode(ChangesVersionMode.AllChanges); } } registerAction2(AllChangesAction); @@ -1449,7 +1378,7 @@ class LastTurnChangesAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); - view?.setVersionMode(ChangesVersionMode.LastTurn); + view?.viewModel.setVersionMode(ChangesVersionMode.LastTurn); } } registerAction2(LastTurnChangesAction); diff --git a/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts b/src/vs/sessions/contrib/changes/browser/changesViewController.ts similarity index 97% rename from src/vs/sessions/contrib/changes/browser/toggleChangesView.ts rename to src/vs/sessions/contrib/changes/browser/changesViewController.ts index ff3ee68fdcd..95a090d25cc 100644 --- a/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewController.ts @@ -20,9 +20,9 @@ interface IPendingTurnState { readonly submittedAt: number; } -export class ToggleChangesViewContribution extends Disposable { +export class ChangesViewController extends Disposable { - static readonly ID = 'workbench.contrib.toggleChangesView'; + static readonly ID = 'workbench.contrib.changesViewController'; private readonly pendingTurnStateByResource = new ResourceMap(); diff --git a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts index 48e508aa528..9b81b8bf26c 100644 --- a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts @@ -23,16 +23,10 @@ import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../github/common/types.js'; import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; +import { CICheckGroup, buildFixChecksPrompt, getCheckGroup, getCheckStateLabel, getFailedChecks } from './fixCIChecksAction.js'; const $ = dom.$; -const enum CICheckGroup { - Running, - Pending, - Failed, - Successful, -} - interface ICICheckListItem { readonly check: IGitHubCICheck; readonly group: CICheckGroup; @@ -397,17 +391,6 @@ function compareChecks(a: IGitHubCICheck, b: IGitHubCICheck): number { return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); } -function getCheckGroup(check: IGitHubCICheck): CICheckGroup { - switch (check.status) { - case GitHubCheckStatus.InProgress: - return CICheckGroup.Running; - case GitHubCheckStatus.Queued: - return CICheckGroup.Pending; - case GitHubCheckStatus.Completed: - return isFailedConclusion(check.conclusion) ? CICheckGroup.Failed : CICheckGroup.Successful; - } -} - function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts { let running = 0; let pending = 0; @@ -434,10 +417,6 @@ function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts { return { running, pending, failed, successful }; } -function getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { - return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); -} - function getChecksSummary(checks: readonly IGitHubCICheck[]): string { const counts = getCheckCounts(checks); const parts: string[] = []; @@ -469,33 +448,6 @@ function getChecksSummary(checks: readonly IGitHubCICheck[]): string { return parts.join(', '); } -function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>): string { - const sections = failedChecks.map(({ check, annotations }) => { - const parts = [ - `Check: ${check.name}`, - `Status: ${getCheckStateLabel(check)}`, - `Conclusion: ${check.conclusion ?? 'unknown'}`, - ]; - - if (check.detailsUrl) { - parts.push(`Details: ${check.detailsUrl}`); - } - - parts.push('', 'Annotations and output:', annotations || 'No output available for this check run.'); - return parts.join('\n'); - }); - - return [ - 'Please fix the failed CI checks for this session immediately.', - 'Use the failed check information below, including annotations and check output, to identify the root causes and make the necessary code changes.', - 'Focus on resolving these CI failures. Avoid unrelated changes unless they are required to fix the checks.', - '', - 'Failed CI checks:', - '', - sections.join('\n\n---\n\n'), - ].join('\n'); -} - function getHeaderIconAndClass(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): { icon: ThemeIcon; className: string } { const counts = getCheckCounts(checks); if (counts.running > 0) { @@ -552,22 +504,3 @@ function getCheckStatusClass(check: IGitHubCICheck): string { return 'ci-status-success'; } } - -function getCheckStateLabel(check: IGitHubCICheck): string { - switch (getCheckGroup(check)) { - case CICheckGroup.Running: - return localize('ci.runningState', "running"); - case CICheckGroup.Pending: - return localize('ci.pendingState', "pending"); - case CICheckGroup.Failed: - return localize('ci.failedState', "failed"); - case CICheckGroup.Successful: - return localize('ci.successfulState', "successful"); - } -} - -function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { - return conclusion === GitHubCheckConclusion.Failure - || conclusion === GitHubCheckConclusion.TimedOut - || conclusion === GitHubCheckConclusion.ActionRequired; -} diff --git a/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts new file mode 100644 index 00000000000..a818cdb020a --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived } from '../../../../base/common/observable.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, IGitHubCICheck } from '../../github/common/types.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +export const hasActiveSessionFailedCIChecks = new RawContextKey('sessions.hasActiveSessionFailedCIChecks', false); + +// --- Shared CI check utilities ------------------------------------------------ + +export const enum CICheckGroup { + Running, + Pending, + Failed, + Successful, +} + +export function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { + return conclusion === GitHubCheckConclusion.Failure + || conclusion === GitHubCheckConclusion.TimedOut + || conclusion === GitHubCheckConclusion.ActionRequired; +} + +export function getCheckGroup(check: IGitHubCICheck): CICheckGroup { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return CICheckGroup.Running; + case GitHubCheckStatus.Queued: + return CICheckGroup.Pending; + case GitHubCheckStatus.Completed: + return isFailedConclusion(check.conclusion) ? CICheckGroup.Failed : CICheckGroup.Successful; + } +} + +export function getCheckStateLabel(check: IGitHubCICheck): string { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + return localize('ci.runningState', "running"); + case CICheckGroup.Pending: + return localize('ci.pendingState', "pending"); + case CICheckGroup.Failed: + return localize('ci.failedState', "failed"); + case CICheckGroup.Successful: + return localize('ci.successfulState', "successful"); + } +} + +export function getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { + return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); +} + +export function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>): string { + const sections = failedChecks.map(({ check, annotations }) => { + const parts = [ + `Check: ${check.name}`, + `Status: ${getCheckStateLabel(check)}`, + `Conclusion: ${check.conclusion ?? 'unknown'}`, + ]; + + if (check.detailsUrl) { + parts.push(`Details: ${check.detailsUrl}`); + } + + parts.push('', 'Annotations and output:', annotations || 'No output available for this check run.'); + return parts.join('\n'); + }); + + return [ + 'Please fix the failed CI checks for this session immediately.', + 'Use the failed check information below, including annotations and check output, to identify the root causes and make the necessary code changes.', + 'Focus on resolving these CI failures. Avoid unrelated changes unless they are required to fix the checks.', + '', + 'Failed CI checks:', + '', + sections.join('\n\n---\n\n'), + ].join('\n'); +} + +/** + * Sets the `hasActiveSessionFailedCIChecks` context key to true when the + * active session has a PR with CI checks and at least one has failed. + */ +class ActiveSessionFailedCIChecksContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFailedCIChecksContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + @IGitHubService gitHubService: IGitHubService, + ) { + super(); + + const ciModelObs = derived(this, reader => { + const session = sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + const context = sessionManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return undefined; + } + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + return undefined; + } + return gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + }); + + this._register(bindContextKey(hasActiveSessionFailedCIChecks, contextKeyService, reader => { + const ciModel = ciModelObs.read(reader); + if (!ciModel) { + return false; + } + const checks = ciModel.checks.read(reader); + return getFailedChecks(checks).length > 0; + })); + } +} + +class FixCIChecksAction extends Action2 { + + static readonly ID = 'sessions.action.fixCIChecks'; + + constructor() { + super({ + id: FixCIChecksAction.ID, + title: localize2('fixCIChecks', 'Fix CI Checks'), + icon: Codicon.lightbulbAutofix, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionFailedCIChecks), + menu: [{ + id: MenuId.ChatEditingSessionApplySubmenu, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionFailedCIChecks), + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const gitHubService = accessor.get(IGitHubService); + const chatWidgetService = accessor.get(IChatWidgetService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const context = sessionManagementService.getGitHubContextForSession(sessionResource); + if (!context || context.prNumber === undefined) { + return; + } + + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.get(); + if (!pr) { + return; + } + + const ciModel = gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + const checks = ciModel.checks.get(); + const failedChecks = getFailedChecks(checks); + if (failedChecks.length === 0) { + return; + } + + const failedCheckDetails = await Promise.all(failedChecks.map(async check => { + const annotations = await ciModel.getCheckRunAnnotations(check.id); + return { check, annotations }; + })); + + const prompt = buildFixChecksPrompt(failedCheckDetails); + const chatWidget = chatWidgetService.getWidgetBySessionResource(sessionResource) + ?? await chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget) { + logService.error('[FixCIChecks] Cannot fix CI checks: no chat widget found for session', sessionResource.toString()); + return; + } + + await chatWidget.acceptInput(prompt, { noCommandDetection: true }); + } +} + +registerWorkbenchContribution2(ActiveSessionFailedCIChecksContextContribution.ID, ActiveSessionFailedCIChecksContextContribution, WorkbenchPhase.AfterRestored); +registerAction2(FixCIChecksAction); diff --git a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css index 87983d53cb6..451457dd543 100644 --- a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css @@ -32,12 +32,15 @@ /* Title - single line, overflow ellipsis */ .ci-status-widget-title { flex: 1; + display: flex; + align-items: center; overflow: hidden; color: var(--vscode-foreground); } .ci-status-widget-title .monaco-icon-label { width: 100%; + height: 18px; } .ci-status-widget-title .monaco-icon-label-container, diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index d7c33c437c4..ccc6c567ee3 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -51,9 +51,9 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { icon: Codicon.vscodeInsiders, precondition: IsActiveSessionBackgroundProviderContext, menu: [{ - id: Menus.TitleBarSessionMenu, + id: Menus.TitleBarRightLayout, group: 'navigation', - order: 10, + order: 0, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), }] }); diff --git a/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts new file mode 100644 index 00000000000..ef48023c1eb --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatSessionHeader.css'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ChatViewId } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; + +/** + * Renders a PR-style header at the top of the chat messages area. + * Displays: session title + folder name (no diff numbers). + */ +class ChatSessionHeaderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.chatSessionHeader'; + + private readonly headerElement: HTMLElement; + private readonly titleElement: HTMLElement; + private readonly repoElement: HTMLElement; + private readonly iconElement: HTMLElement; + private readonly markDoneButton: Button; + private readonly modelChangeListener = this._register(new MutableDisposable()); + private lastRenderState: string | undefined; + private isRendering = false; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IChatService private readonly chatService: IChatService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IViewsService private readonly viewsService: IViewsService, + ) { + super(); + + // Create header DOM (will be inserted when a chat container is found) + this.headerElement = $('.chat-session-header'); + + const headerContent = append(this.headerElement, $('.chat-session-header-content')); + headerContent.setAttribute('role', 'button'); + headerContent.setAttribute('aria-label', localize('showSessions', "Show Sessions")); + headerContent.tabIndex = 0; + + // Title row: title + done button + const titleRow = append(headerContent, $('.chat-session-header-title-row')); + this.titleElement = append(titleRow, $('span.chat-session-header-title')); + + // Mark as Done button + const buttonContainer = append(titleRow, $('.chat-session-header-actions')); + this._register(addDisposableListener(buttonContainer, EventType.CLICK, e => { + e.stopPropagation(); + })); + this.markDoneButton = this._register(new Button(buttonContainer, { supportIcons: true, ...defaultButtonStyles })); + this.markDoneButton.label = `$(check) ${localize('markAsDone', "Mark as Done")}`; + this._register(this.markDoneButton.onDidClick(() => this.markAsDone())); + + // Repo row: icon + folder name + const repoRow = append(headerContent, $('span.chat-session-header-repo-row')); + this.iconElement = append(repoRow, $('span.chat-session-header-icon')); + this.repoElement = append(repoRow, $('span.chat-session-header-repo')); + + // Click handler — show sessions picker (same as titlebar) + this._register(addDisposableListener(headerContent, EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + this.showSessionsPicker(); + })); + + this._register(addDisposableListener(headerContent, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.showSessionsPicker(); + } + })); + + // Watch active session changes + this._register(autorun(reader => { + const activeSession = this.sessionsManagementService.activeSession.read(reader); + this.trackModelChanges(activeSession?.resource); + this.lastRenderState = undefined; + this.render(); + })); + + // Watch session data changes + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this.lastRenderState = undefined; + this.render(); + })); + + // Periodically try to inject into the DOM (chat widget may not exist yet) + this.ensureInjected(); + } + + private tryInject(): boolean { + const view = this.viewsService.getViewWithId(ChatViewId); + if (!view?.element) { + return false; + } + // Re-inject if the header is not a child of the current view element + // (view may have been recreated on session switch) + if (this.headerElement.parentElement !== view.element) { + view.element.insertBefore(this.headerElement, view.element.firstChild); + } + return true; + } + + private ensureInjected(): void { + if (!this.tryInject()) { + // Retry when the chat view becomes visible + this._register(this.viewsService.onDidChangeViewVisibility(e => { + if (e.id === ChatViewId && e.visible) { + this.tryInject(); + } + })); + } + } + + private render(): void { + if (this.isRendering) { + return; + } + this.isRendering = true; + try { + // Ensure header is in the DOM (may have been created before the view mounted) + this.tryInject(); + + const label = this.getLabel(); + const icon = this.getIcon(); + const repoLabel = this.getRepoLabel(); + + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; + if (this.lastRenderState === renderState) { + return; + } + this.lastRenderState = renderState; + + // Icon + this.iconElement.className = 'chat-session-header-icon'; + if (icon) { + this.iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + this.iconElement.style.display = ''; + } else { + this.iconElement.style.display = 'none'; + } + + // Title + this.titleElement.textContent = label; + + // Repo folder + if (repoLabel) { + this.repoElement.textContent = repoLabel; + this.repoElement.style.display = ''; + } else { + this.repoElement.style.display = 'none'; + } + + // Show the button only when there is an active session with an agent session + const activeSession = this.sessionsManagementService.getActiveSession(); + const hasAgentSession = activeSession ? !!this.agentSessionsService.getSession(activeSession.resource) : false; + this.markDoneButton.element.style.display = hasAgentSession ? '' : 'none'; + } finally { + this.isRendering = false; + } + } + + private getLabel(): string { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (activeSession?.label) { + return activeSession.label; + } + if (activeSession) { + const model = this.chatService.getSession(activeSession.resource); + if (model?.title) { + return model.title; + } + } + return localize('newSession', "New Session"); + } + + private getIcon(): ThemeIcon | undefined { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession) { + return undefined; + } + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + if (agentSession.providerType === AgentSessionProviders.Background) { + const hasWorktree = typeof agentSession.metadata?.worktreePath === 'string'; + return hasWorktree ? Codicon.worktree : Codicon.folder; + } + return agentSession.icon; + } + const provider = getAgentSessionProvider(activeSession.resource); + if (provider !== undefined) { + return getAgentSessionProviderIcon(provider); + } + return undefined; + } + + private getRepoLabel(): string | undefined { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession?.repository) { + return undefined; + } + return basename(activeSession.repository); + } + + private markAsDone(): void { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession) { + return; + } + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + agentSession.setArchived(true); + } + this.sessionsManagementService.openNewSessionView(); + } + + private showSessionsPicker(): void { + const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { + overrideSessionOpen: (session, openOptions) => this.sessionsManagementService.openSession(session.resource, openOptions) + }); + picker.pickAgentSession(); + } + + private trackModelChanges(resource: URI | undefined): void { + this.modelChangeListener.clear(); + if (!resource) { + return; + } + const model = this.chatService.getSession(resource); + if (!model) { + return; + } + this.modelChangeListener.value = model.onDidChange(e => { + if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { + this.lastRenderState = undefined; + this.render(); + } + }); + } +} + +registerWorkbenchContribution2(ChatSessionHeaderContribution.ID, ChatSessionHeaderContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css new file mode 100644 index 00000000000..f0e0a6ea769 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Chat Session Header (PR-style) ---- */ + +.agent-sessions-workbench .chat-session-header { + position: relative; + width: 100%; + max-width: 950px; + margin: 0 auto; + padding: 0px 8px; + box-sizing: border-box; +} + +.agent-sessions-workbench .chat-session-header-content { + display: flex; + flex-direction: column; + min-width: 0; + cursor: pointer; + padding: 2px 8px 6px; +} + +.agent-sessions-workbench .chat-session-header-content:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + border-radius: 4px; +} + +.agent-sessions-workbench .chat-session-header-title-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6px; +} + +/* Title — large by default, like a PR title */ +.agent-sessions-workbench .chat-session-header-title { + align-self: flex-start; + font-size: 22px; + font-weight: 600; + line-height: 1.3; + color: var(--vscode-foreground); + word-break: break-word; + border-radius: 4px 4px 4px 0; + padding: 2px 4px; + transition: background 150ms ease; +} + +.agent-sessions-workbench .chat-session-header-title:hover, +.agent-sessions-workbench .chat-session-header-content:has(.chat-session-header-repo-row:hover) .chat-session-header-title { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .chat-session-header-repo-row:hover, +.agent-sessions-workbench .chat-session-header-content:has(.chat-session-header-title:hover) .chat-session-header-repo-row { + background: var(--vscode-toolbar-hoverBackground); +} + +/* Repo row: icon + folder name */ +.agent-sessions-workbench .chat-session-header-repo-row { + align-self: flex-start; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + border-radius: 0 0 4px 4px; + padding: 2px 4px; + transition: background 150ms ease; +} + +/* Icon */ +.agent-sessions-workbench .chat-session-header-icon { + font-size: 14px; + opacity: 0.6; + flex-shrink: 0; +} + +/* Repo folder — secondary text */ +.agent-sessions-workbench .chat-session-header-repo { + font-size: 13px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +/* ---- Mark as Done button ---- */ + +.agent-sessions-workbench .chat-session-header-actions { + flex-shrink: 0; +} + +.agent-sessions-workbench .chat-session-header-actions .monaco-button { + white-space: nowrap; + padding: 4px 6px 4px 0px; + margin-top: 6px; + background: none !important; + border: none !important; + color: var(--vscode-textLink-foreground) !important; + font-size: 12px; + cursor: pointer; +} + +.agent-sessions-workbench .chat-session-header-actions .monaco-button:hover { + color: var(--vscode-textLink-activeForeground) !important; + text-decoration: underline; + outline: 1px solid var(--vscode-focusBorder); + border-radius: 4px; +} + +/* ---- Reduced motion ---- */ + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .chat-session-header-title, + .agent-sessions-workbench .chat-session-header-repo-row { + transition: none; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 9acac072639..0c264d25f9a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -15,6 +15,9 @@ import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatImageCarouselService } from '../../../../workbench/contrib/chat/browser/chatImageCarouselService.js'; +import { coerceImageBuffer } from '../../../../workbench/contrib/chat/common/chatImageExtraction.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; @@ -83,6 +86,7 @@ export class NewChatContextAttachments extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, + @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, ) { super(); this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); @@ -133,8 +137,19 @@ export class NewChatContextAttachments extends Disposable { } } - // Click to open the resource - if (resource) { + // Click to open the resource or image + const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined; + if (imageData) { + pill.style.cursor = 'pointer'; + this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { + if (this.configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { + const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name }); + await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData); + } else if (resource) { + await this.openerService.open(resource, { fromUserGesture: true }); + } + })); + } else if (resource) { pill.style.cursor = 'pointer'; this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { await this.openerService.open(resource, { fromUserGesture: true }); diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 6845c427983..331edec68ab 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -37,7 +37,7 @@ import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js' import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -56,7 +56,7 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { SessionTypePicker, IsolationPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; -import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; +import { AgentHostNewSession, INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; import { CloudModelPicker } from './modelPicker.js'; import { WorkspacePicker } from './workspacePicker.js'; import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; @@ -70,6 +70,8 @@ import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/ import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; import { NewChatPermissionPicker } from './newChatPermissionPicker.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { getRemoteAgentHostSessionTarget } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; const MIN_EDITOR_HEIGHT = 50; @@ -180,6 +182,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { @IGitService private readonly gitService: IGitService, @IStorageService private readonly storageService: IStorageService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, ) { super(); this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); @@ -325,7 +328,20 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } private async _createNewSession(project?: SessionWorkspace): Promise { - const target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + const isAgentHost = project?.isRemoteAgentHost ?? false; + let target: AgentSessionTarget; + if (isAgentHost) { + // Find the matching remote agent host session type from the URI authority + // TODO@roblourens HACK - view should not do this + const remoteTarget = getRemoteAgentHostSessionTarget(this.remoteAgentHostService.connections, project!.uri.authority); + if (!remoteTarget) { + this.logService.error(`Failed to find remote agent host session type for authority: ${project!.uri.authority}`); + return; + } + target = remoteTarget; + } else { + target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + } const resource = getResourceForNewChatSession({ type: target, @@ -334,7 +350,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { }); try { - const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource); + const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource, { agentHost: isAgentHost }); if (project) { session.setProject(project); } @@ -370,7 +386,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._sessionTypePicker.setProject(session.project); - if (session instanceof RemoteNewSession) { + if (session instanceof AgentHostNewSession) { + this._renderAgentHostSessionPickers(); + } else if (session instanceof RemoteNewSession) { this._renderRemoteSessionPickers(session, true); listeners.add(session.onDidChangeOptionGroups(() => { this._renderRemoteSessionPickers(session); @@ -688,6 +706,24 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._workspacePicker.render(pickersRow); } + // --- Agent Host session pickers --- + + /** + * Agent Host sessions use the standard model picker and mode picker + * but don't need repo, folder, isolation, branch, or cloud option pickers. + */ + private _renderAgentHostSessionPickers(): void { + this._clearAllPickers(); + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = ''; + } + this._modePicker.setVisible(true); + this._permissionPicker.setVisible(false); + this._cloudModelPicker.setVisible(false); + this._branchPicker.setVisible(false); + this._isolationPicker.setVisible(false); + } + // --- Local session pickers --- private _renderLocalSessionPickers(): void { @@ -960,11 +996,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { * For Local/Background targets, checks the folder picker. * For other targets, checks extension-contributed repo/folder option groups. */ - private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionProviders): boolean { + private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionTarget): boolean { return !!this._newSession.value?.project; } - private _openRepoOrFolderPicker(_sessionType: AgentSessionProviders): void { + private _openRepoOrFolderPicker(_sessionType: AgentSessionTarget): void { this._workspacePicker.showPicker(); } @@ -1095,6 +1131,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } } + setProject(projectUri: URI): void { + const project = new SessionWorkspace(projectUri); + this._workspacePicker.setSelectedProject(project, true); + } + sendQuery(text: string): void { const model = this._editor?.getModel(); if (model) { @@ -1162,6 +1203,10 @@ export class NewChatViewPane extends ViewPane { this._widget?.sendQuery(text); } + setProject(projectUri: URI): void { + this._widget?.setProject(projectUri); + } + override setVisible(visible: boolean): void { super.setVisible(visible); if (visible) { diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index 06ac91248a2..d567b8ffbb8 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -6,11 +6,10 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IsolationMode } from './sessionTargetPicker.js'; import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -33,7 +32,7 @@ export interface ISessionOptionGroup { */ export interface INewSession extends IDisposable { readonly resource: URI; - readonly target: AgentSessionProviders; + readonly target: AgentSessionTarget; readonly project: SessionWorkspace | undefined; readonly isolationMode: IsolationMode | undefined; readonly branch: string | undefined; @@ -102,7 +101,6 @@ export class CopilotCLISession extends Disposable implements INewSession { readonly resource: URI, defaultRepoUri: URI | undefined, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @ILogService private readonly logService: ILogService, ) { super(); if (defaultRepoUri) { @@ -168,10 +166,7 @@ export class CopilotCLISession extends Disposable implements INewSession { } else { this.selectedOptions.set(optionId, value); } - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify session option ${optionId} change:`, err)); + this.chatSessionsService.setSessionOption(this.resource, optionId, value); } } @@ -211,10 +206,9 @@ export class RemoteNewSession extends Disposable implements INewSession { constructor( readonly resource: URI, - readonly target: AgentSessionProviders, + readonly target: AgentSessionTarget, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -272,10 +266,7 @@ export class RemoteNewSession extends Disposable implements INewSession { } this._onDidChange.fire('options'); this._onDidChange.fire('disabled'); - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err)); + this.chatSessionsService.setSessionOption(this.resource, optionId, value); } // --- Option group accessors --- @@ -374,3 +365,78 @@ function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean { return group.id === 'repositories'; } + +/** + * New session for agent host sessions (local or remote agent host processes). + * Agent host sessions use local model and mode pickers but don't need + * isolation mode, branch selection, or cloud option groups. + */ +export class AgentHostNewSession extends Disposable implements INewSession { + + private _project: SessionWorkspace | undefined; + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + private _query: string | undefined; + private _attachedContext: IChatRequestVariableEntry[] | undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + readonly selectedOptions = new Map(); + + get project(): SessionWorkspace | undefined { return this._project; } + get isolationMode(): undefined { return undefined; } + get branch(): undefined { return undefined; } + get modelId(): string | undefined { return this._modelId; } + get mode(): IChatMode | undefined { return this._mode; } + get query(): string | undefined { return this._query; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get disabled(): boolean { return false; } + + constructor( + readonly resource: URI, + readonly target: AgentSessionTarget, + ) { + super(); + } + + setProject(project: SessionWorkspace): void { + this._project = project; + this._onDidChange.fire('repoUri'); + } + + setIsolationMode(_mode: IsolationMode): void { + // No-op for agent host sessions + } + + setBranch(_branch: string | undefined): void { + // No-op for agent host sessions + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + } + + setMode(mode: IChatMode | undefined): void { + if (this._mode?.id !== mode?.id) { + this._mode = mode; + this._onDidChange.fire('agent'); + } + } + + setQuery(query: string): void { + this._query = query; + } + + setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void { + this._attachedContext = context; + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts index a31509424fe..83d70b4c131 100644 --- a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts @@ -18,6 +18,10 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { agentHostAuthority } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; +import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_PROJECT = 'sessions.lastPickedProject'; @@ -33,6 +37,7 @@ const LEGACY_STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos'; const COMMAND_BROWSE_FOLDERS = 'command:browseFolders'; const COMMAND_BROWSE_REPOS = 'command:browseRepos'; +const COMMAND_BROWSE_REMOTE_AGENT_HOSTS = 'command:browseRemoteAgentHosts'; /** * Serializable form of a project entry for storage. @@ -40,6 +45,8 @@ const COMMAND_BROWSE_REPOS = 'command:browseRepos'; interface IStoredProject { readonly uri: UriComponents; readonly checked?: boolean; + /** Cached display name for remote agent host connections. */ + readonly remoteName?: string; } /** @@ -72,6 +79,8 @@ export class WorkspacePicker extends Disposable { @IFileDialogService private readonly fileDialogService: IFileDialogService, @ICommandService private readonly commandService: ICommandService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, + @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(); @@ -200,6 +209,8 @@ export class WorkspacePicker extends Disposable { this._browseForFolder(); } else if (uriStr === COMMAND_BROWSE_REPOS) { this._browseForRepo(); + } else if (uriStr === COMMAND_BROWSE_REMOTE_AGENT_HOSTS) { + this._browseForRemoteAgentHost(); } else { this._selectProject(this._fromStored(item)); } @@ -256,7 +267,7 @@ export class WorkspacePicker extends Disposable { private _selectProject(project: SessionWorkspace, fireEvent = true): void { this._selectedProject = project; - const stored = this._toStored(project); + const stored = this._withCachedRemoteName(this._toStored(project)); this._addToRecents(stored); this.storageService.store(STORAGE_KEY_LAST_PROJECT, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); @@ -292,7 +303,65 @@ export class WorkspacePicker extends Disposable { } } + private async _browseForRemoteAgentHost(): Promise { + const connections = this.remoteAgentHostService.connections; + if (connections.length === 0) { + return; + } + + // Show remote picker even with a single connection so the user + // can see which remote they are connecting to. + let selectedAddress: string; + let selectedName: string; + let defaultDirectory: string | undefined; + { + const picks = connections.map(c => ({ + label: c.name, + description: c.address, + address: c.address, + defaultDirectory: c.defaultDirectory, + })); + + const picked = await this.quickInputService.pick(picks, { + title: localize('selectRemote', "Select Remote"), + placeHolder: localize('selectRemotePlaceholder', "Choose a remote agent host"), + }); + if (!picked) { + return; + } + selectedAddress = picked.address; + selectedName = picked.label; + defaultDirectory = picked.defaultDirectory; + } + + // Open a folder picker scoped to the remote filesystem. + // The defaultUri carries both the scheme (agenthost) and authority + // (sanitized address), so SimpleFileDialog stays scoped to this + // particular remote connection. + const authority = agentHostAuthority(selectedAddress); + const defaultUri = defaultDirectory + ? agentHostUri(authority, defaultDirectory) + : agentHostUri(authority, '/'); + + try { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectRemoteFolder', "Select Folder on {0}", selectedName), + availableFileSystems: [AGENT_HOST_FS_SCHEME], + defaultUri, + }); + if (selected?.[0]) { + this._selectProject(new SessionWorkspace(selected[0])); + } + } catch { + // dialog was cancelled or failed + } + } + private _addToRecents(stored: IStoredProject): void { + stored = this._withCachedRemoteName(stored); this._recentProjects = [ stored, ...this._recentProjects.filter(p => !this._isSameProject(p, stored)), @@ -305,51 +374,65 @@ export class WorkspacePicker extends Disposable { } private _buildItems(): IActionListItem[] { - const seen = new Set(); const items: IActionListItem[] = []; // Collect all projects (current + recents), deduped const allProjects: IStoredProject[] = []; if (this._selectedProject) { - const stored = this._toStored(this._selectedProject); - seen.add(this._projectKey(stored)); + const stored = this._withCachedRemoteName(this._toStored(this._selectedProject)); allProjects.push(stored); } for (const project of this._recentProjects) { - const key = this._projectKey(project); - if (!seen.has(key)) { - seen.add(key); + if (!allProjects.some(p => this._isSameProject(p, project))) { allProjects.push(project); } } - // Split into folders and repos, sort each group alphabetically - const isStoredFolder = (p: IStoredProject) => URI.revive(p.uri).scheme !== GITHUB_REMOTE_FILE_SCHEME; + // Split into folders, repos, and remotes, sort each group alphabetically + const isStoredFolder = (p: IStoredProject) => { + const scheme = URI.revive(p.uri).scheme; + return scheme !== GITHUB_REMOTE_FILE_SCHEME && scheme !== AGENT_HOST_FS_SCHEME; + }; + const isStoredRemote = (p: IStoredProject) => URI.revive(p.uri).scheme === AGENT_HOST_FS_SCHEME; const folders = allProjects.filter(p => isStoredFolder(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); - const repos = allProjects.filter(p => !isStoredFolder(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); + const repos = allProjects.filter(p => !isStoredFolder(p) && !isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); + const remotes = allProjects.filter(p => isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); - const selectedKey = this._selectedProject ? this._projectKey(this._toStored(this._selectedProject)) : undefined; + const selectedStored = this._selectedProject ? this._toStored(this._selectedProject) : undefined; + const isSelected = (p: IStoredProject) => !!selectedStored && this._isSameProject(p, selectedStored); // Folders first for (const project of folders) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.folder }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } // Then repos for (const project of repos) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.repo }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, + onRemove: () => this._removeProject(project), + }); + } + + // Then remotes + for (const project of remotes) { + const selected = isSelected(project); + items.push({ + kind: ActionListItemKind.Action, + label: this._getStoredProjectLabel(project), + group: { title: '', icon: Codicon.remote }, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } @@ -370,6 +453,14 @@ export class WorkspacePicker extends Disposable { group: { title: '', icon: Codicon.repo }, item: { uri: URI.parse(COMMAND_BROWSE_REPOS).toJSON() }, }); + if (this.remoteAgentHostService.connections.length > 0) { + items.push({ + kind: ActionListItemKind.Action, + label: localize('browseRemotes', "Browse Remotes..."), + group: { title: '', icon: Codicon.remote }, + item: { uri: URI.parse(COMMAND_BROWSE_REMOTE_AGENT_HOSTS).toJSON() }, + }); + } return items; } @@ -387,7 +478,9 @@ export class WorkspacePicker extends Disposable { dom.clearNode(this._triggerElement); const project = this._selectedProject; const label = project ? this._getProjectLabel(project) : localize('pickWorkspace', "Pick a Workspace"); - const icon = project ? (project.isFolder ? Codicon.folder : Codicon.repo) : Codicon.project; + const icon = project + ? (project.isRemoteAgentHost ? Codicon.remote : project.isFolder ? Codicon.folder : Codicon.repo) + : Codicon.project; dom.append(this._triggerElement, renderIcon(icon)); const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); @@ -396,11 +489,17 @@ export class WorkspacePicker extends Disposable { } private _getProjectLabel(project: SessionWorkspace): string { - return this._getStoredProjectLabel({ uri: project.uri.toJSON() }); + return this._getStoredProjectLabel(this._withCachedRemoteName(this._toStored(project))); } private _getStoredProjectLabel(project: IStoredProject): string { const uri = URI.revive(project.uri); + // TODO@roblourens HACK + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const folderName = basename(uri) || uri.path || '/'; + const remoteName = this._getRemoteName(uri.authority) ?? project.remoteName ?? uri.authority; + return `${folderName} [${remoteName}]`; + } if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) { return basename(uri); } @@ -408,18 +507,46 @@ export class WorkspacePicker extends Disposable { return uri.path.substring(1).replace(/\/HEAD$/, ''); } + /** + * Resolves a sanitized authority back to a user-facing remote name. + */ + private _getRemoteName(authority: string): string | undefined { + for (const conn of this.remoteAgentHostService.connections) { + if (agentHostAuthority(conn.address) === authority) { + return conn.name; + } + } + return undefined; + } + private _toStored(project: SessionWorkspace): IStoredProject { - return { - uri: project.uri.toJSON(), - }; + const uri = project.uri; + const stored: IStoredProject = { uri: uri.toJSON() }; + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const remoteName = this._getRemoteName(uri.authority); + if (remoteName) { + return { ...stored, remoteName }; + } + } + return stored; } private _fromStored(stored: IStoredProject): SessionWorkspace { return new SessionWorkspace(URI.revive(stored.uri)); } - private _projectKey(project: IStoredProject): string { - return URI.revive(project.uri).toString(); + /** + * If the stored project is missing a cached remoteName, tries to recover + * it from the recents list so labels remain stable across restarts. + */ + private _withCachedRemoteName(stored: IStoredProject): IStoredProject { + if (!stored.remoteName && URI.revive(stored.uri).scheme === AGENT_HOST_FS_SCHEME) { + const cached = this._recentProjects.find(p => this._isSameProject(p, stored)); + if (cached?.remoteName) { + return { ...stored, remoteName: cached.remoteName }; + } + } + return stored; } private _isSameProject(a: IStoredProject, b: IStoredProject): boolean { diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts index e7a79bda11c..a4eb5afd410 100644 --- a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -4,19 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; -import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -/** - * Extended storage type for AI Customization that includes built-in prompts - * shipped with the application, alongside the core `PromptsStorage` values. - */ -export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; - -/** - * Storage type discriminator for built-in prompts shipped with the application. - */ -export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; +// Re-export from common for backward compatibility +export type { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; /** * Prompt path for built-in prompts bundled with the Sessions app. diff --git a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts new file mode 100644 index 00000000000..60f669931b3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; + +import { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.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'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ExtUri } from '../../../../../base/common/resources.js'; +import { IRemoteAgentHostService, IRemoteAgentHostConnectionInfo } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { WorkspacePicker } from '../../browser/workspacePicker.js'; +import { SessionWorkspace, GITHUB_REMOTE_FILE_SCHEME } from '../../../sessions/common/sessionWorkspace.js'; +import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; +import { agentHostAuthority } from '../../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; + +suite('WorkspacePicker', () => { + + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + let instantiationService: TestInstantiationService; + let connections: IRemoteAgentHostConnectionInfo[]; + + setup(() => { + instantiationService = ds.add(new TestInstantiationService()); + connections = []; + + instantiationService.stub(IStorageService, ds.add(new InMemoryStorageService())); + instantiationService.stub(IActionWidgetService, new class extends mock() { + override get isVisible() { return false; } + }); + instantiationService.stub(IFileDialogService, new class extends mock() { }); + instantiationService.stub(ICommandService, new class extends mock() { }); + instantiationService.stub(IUriIdentityService, new class extends mock() { + override readonly extUri = new ExtUri(uri => false); + }); + instantiationService.stub(IRemoteAgentHostService, new class extends mock() { + override readonly onDidChangeConnections = Event.None; + override get connections() { return connections; } + override getConnection() { return undefined; } + }); + instantiationService.stub(IQuickInputService, new class extends mock() { }); + }); + + function createPicker(): WorkspacePicker { + return ds.add(instantiationService.createInstance(WorkspacePicker)); + } + + test('setSelectedProject with local folder', () => { + const picker = createPicker(); + const folder = new SessionWorkspace(URI.file('/home/user/project')); + + picker.setSelectedProject(folder); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isFolder, true); + assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + }); + + test('setSelectedProject with remote agent host URI', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + const project = new SessionWorkspace(remoteUri); + + picker.setSelectedProject(project); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isRemoteAgentHost, true); + assert.strictEqual(picker.selectedProject.uri.scheme, AGENT_HOST_FS_SCHEME); + assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + }); + + test('setSelectedProject with GitHub repo URI', () => { + const picker = createPicker(); + const repoUri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' }); + const project = new SessionWorkspace(repoUri); + + picker.setSelectedProject(project); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isRepo, true); + }); + + test('onDidSelectProject fires when project is selected', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/remote/path'); + const project = new SessionWorkspace(remoteUri); + + let fired: SessionWorkspace | undefined; + ds.add(picker.onDidSelectProject(p => { fired = p; })); + + picker.setSelectedProject(project, true); + + assert.ok(fired); + assert.strictEqual(fired.isRemoteAgentHost, true); + assert.strictEqual(fired.uri.path, '/remote/path'); + }); + + test('onDidSelectProject does not fire when fireEvent is false', () => { + const picker = createPicker(); + const project = new SessionWorkspace(URI.file('/some/folder')); + + let fired = false; + ds.add(picker.onDidSelectProject(() => { fired = true; })); + + picker.setSelectedProject(project, false); + + assert.strictEqual(fired, false); + assert.ok(picker.selectedProject); + }); + + test('clearSelection clears the selected project', () => { + const picker = createPicker(); + picker.setSelectedProject(new SessionWorkspace(URI.file('/folder')), false); + + assert.ok(picker.selectedProject); + + picker.clearSelection(); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents clears selection if it matches', () => { + const picker = createPicker(); + const uri = URI.file('/folder'); + picker.setSelectedProject(new SessionWorkspace(uri), false); + + picker.removeFromRecents(uri); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents preserves selection if it does not match', () => { + const picker = createPicker(); + const selectedUri = URI.file('/selected'); + picker.setSelectedProject(new SessionWorkspace(selectedUri), false); + + picker.removeFromRecents(URI.file('/other')); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.uri.path, '/selected'); + }); + + test('remote project persists and restores from storage', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + // Create picker and select a remote project + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Create a second picker -- it should restore from storage + const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); + assert.ok(picker2.selectedProject); + assert.strictEqual(picker2.selectedProject.isRemoteAgentHost, true); + assert.strictEqual(picker2.selectedProject.uri.path, '/home/user/project'); + assert.strictEqual(picker2.selectedProject.uri.authority, authority); + }); + + test('trigger label uses cached remoteName when connection is unavailable', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + const address = 'http://myremote:3000'; + const authority = agentHostAuthority(address); + + // Simulate a live connection so remoteName gets cached + connections = [{ address, name: 'macbook', clientId: 'test-client' }]; + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Simulate startup with no connections available + connections = []; + const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); + + // Render and check the trigger label uses cached "macbook", not encoded authority + const container = document.createElement('div'); + picker2.render(container); + const label = container.querySelector('.sessions-chat-dropdown-label'); + assert.ok(label); + assert.strictEqual(label.textContent, 'project [macbook]'); + }); +}); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index 504888849bd..551cbc4d128 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -5,7 +5,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -127,7 +127,7 @@ class CodeReviewToolbarContribution extends Disposable implements IWorkbenchCont super(); const canRunCodeReviewContext = canRunSessionCodeReviewContextKey.bindTo(contextKeyService); - const sessionsChangedSignal = observableFromEvent(this, this._agentSessionsService.model.onDidChangeSessions, () => undefined); + const sessionsChangedSignal = observableSignalFromEvent(this, this._agentSessionsService.model.onDidChangeSessions); this._register(autorun(reader => { const activeSession = this._sessionManagementService.activeSession.read(reader); diff --git a/src/vs/sessions/contrib/git/browser/git.contribution.ts b/src/vs/sessions/contrib/git/browser/git.contribution.ts deleted file mode 100644 index ad28526fdb6..00000000000 --- a/src/vs/sessions/contrib/git/browser/git.contribution.ts +++ /dev/null @@ -1,137 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { GitBranch, GitRepositoryState, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; - -const hasUpstreamBranchContextKey = new RawContextKey('agentSessionGitHasUpstreamBranch', false, { - type: 'boolean', - description: localize('agentSessionGitHasUpstreamBranch', "True when the active agent session worktree has an upstream branch."), -}); - -class GitSyncContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'sessions.contrib.gitSync'; - - private readonly _isSyncingObs = observableValue(this, false); - - constructor( - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, - @IGitService private readonly gitService: IGitService, - ) { - super(); - - const hasUpstreamBranch = hasUpstreamBranchContextKey.bindTo(this.contextKeyService); - - const activeSessionWorktreeObs = derived(reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - return activeSession?.worktree; - }); - - const activeSessionRepositoryPromiseObs = derived(reader => { - const worktreeUri = activeSessionWorktreeObs.read(reader); - if (!worktreeUri) { - return constObservable(undefined); - } - - return new ObservablePromise(this.gitService.openRepository(worktreeUri)).resolvedValue; - }); - - const activeSessionRepositoryStateObs = derived(reader => { - const activeSessionRepository = activeSessionRepositoryPromiseObs.read(reader).read(reader); - if (activeSessionRepository === undefined) { - return undefined; - } - - return activeSessionRepository.state.read(reader); - }); - - this._register(autorun(reader => { - const isSyncing = this._isSyncingObs.read(reader); - const activeSessionRepositoryState = activeSessionRepositoryStateObs.read(reader); - if (!activeSessionRepositoryState) { - hasUpstreamBranch.set(false); - return; - } - - const head = activeSessionRepositoryState.HEAD; - hasUpstreamBranch.set(head?.upstream !== undefined); - - if (!head?.upstream) { - return; - } - - reader.store.add(registerSyncAction(head, isSyncing, (syncing) => { - this._isSyncingObs.set(syncing, undefined); - })); - })); - } -} - -function registerSyncAction(branch: GitBranch, isSyncing: boolean, setSyncing: (syncing: boolean) => void): IDisposable { - const ahead = branch.ahead ?? 0; - const behind = branch.behind ?? 0; - - const titleSegments = [localize('synchronizeChangesTitle', "Sync Changes")]; - if (behind > 0) { - titleSegments.push(`${behind}↓`); - } - if (ahead > 0) { - titleSegments.push(`${ahead}↑`); - } - - const icon = isSyncing - ? ThemeIcon.modify(Codicon.sync, 'spin') - : Codicon.sync; - - class SynchronizeChangesAction extends Action2 { - static readonly ID = 'chatEditing.synchronizeChanges'; - - constructor() { - super({ - id: SynchronizeChangesAction.ID, - title: titleSegments.join(' '), - tooltip: localize('synchronizeChanges', "Synchronize Changes with Git (Behind {0}, Ahead {1})", behind, ahead), - icon, - category: CHAT_CATEGORY, - menu: [ - { - id: MenuId.ChatEditingSessionApplySubmenu, - group: 'navigation', - order: 0, - when: hasUpstreamBranchContextKey, - }, - ], - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const commandService = accessor.get(ICommandService); - const sessionManagementService = accessor.get(ISessionsManagementService); - const worktreeUri = sessionManagementService.getActiveSession()?.worktree; - setSyncing(true); - try { - await commandService.executeCommand('git.sync', worktreeUri); - } finally { - setSyncing(false); - } - } - } - return registerAction2(SynchronizeChangesAction); -} - -registerWorkbenchContribution2(GitSyncContribution.ID, GitSyncContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md new file mode 100644 index 00000000000..b45c0aaf863 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md @@ -0,0 +1,313 @@ +# Remote Agent Host Chat Agents - Architecture + +This document describes how remote agent host chat agents are registered, how +sessions are created, and the URI/target conventions used throughout the system. + +## Overview + +A **remote agent host** is a VS Code agent host process running on another +machine, connected over WebSocket. The user configures remote addresses in the +`chat.remoteAgentHosts` setting. Each remote host may expose one or more agent +backends (currently only the `copilot` provider is supported). The system +discovers these agents, dynamically registers them as chat session types, and +creates sessions that stream turns via the agent host protocol. + +``` +┌─────────────┐ WebSocket ┌───────────────────┐ +│ VS Code │ ◄──────────────► │ Remote Agent Host │ +│ (client) │ AHP protocol │ (server) │ +└─────────────┘ └───────────────────┘ +``` + +## Connection Lifecycle + +### 1. Configuration + +Connections are configured via the `chat.remoteAgentHosts` setting: + +```jsonc +"chat.remoteAgentHosts": [ + { "address": "http://192.168.1.10:3000", "name": "dev-box", "connectionToken": "..." } +] +``` + +Each entry is an `IRemoteAgentHostEntry` with `address`, `name`, and optional +`connectionToken`. + +### 2. Service Layer + +`IRemoteAgentHostService` (`src/vs/platform/agentHost/common/remoteAgentHostService.ts`) +manages WebSocket connections. The Electron implementation reads the setting, +creates `RemoteAgentHostProtocolClient` instances for each address, and fires +`onDidChangeConnections` when connections are established or lost. + +Each connection satisfies the `IAgentConnection` interface (which extends +`IAgentService`), providing: + +- `subscribe(resource)` / `unsubscribe(resource)` - state subscriptions +- `dispatchAction(action, clientId, seq)` - send client actions +- `onDidAction` / `onDidNotification` - receive server events +- `createSession(config)` - create a new backend session +- `browseDirectory(uri)` - list remote filesystem contents +- `clientId` - unique connection identifier for optimistic reconciliation + +### 3. Connection Metadata + +Each active connection exposes `IRemoteAgentHostConnectionInfo`: + +```typescript +{ + address: string; // e.g. "http://192.168.1.10:3000" + name: string; // e.g. "dev-box" (from setting) + clientId: string; // assigned during handshake + defaultDirectory?: string; // home directory on the remote machine +} +``` + +## Agent Discovery + +### Root State Subscription + +`RemoteAgentHostContribution` (`src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts`) +is the central orchestrator. For each connection, it subscribes to `ROOT_STATE_URI` +(`agenthost:/root`) to discover available agents. + +The root state (`IRootState`) contains: + +```typescript +{ + agents: IAgentInfo[]; // discovered agent backends + activeSessions?: number; // count of active sessions +} +``` + +Each `IAgentInfo` describes an agent: + +```typescript +{ + provider: string; // e.g. "copilot" + displayName: string; // e.g. "Copilot" + description: string; + models: ISessionModelInfo[]; // available language models +} +``` + +### Authority Encoding + +Remote addresses are encoded into URI-safe authority strings via +`agentHostAuthority(address)`: + +- Alphanumeric addresses pass through unchanged +- "Normal" addresses (`[a-zA-Z0-9.:-]`) get colons replaced with `__` +- Everything else is url-safe base64 encoded with a `b64-` prefix + +Examples: +- `localhost:8081` → `localhost__8081` +- `192.168.1.1:8080` → `192.168.1.1__8080` +- `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` + +## Agent Registration + +When `_registerAgent()` is called for a discovered copilot agent from address `X`: + +### Naming Conventions + +| Concept | Value | Example | +|---------|-------|---------| +| **Authority** | `agentHostAuthority(address)` | `localhost__8081` | +| **Session type** | `remote-${authority}-${provider}` | `remote-localhost__8081-copilot` | +| **Agent ID** | same as session type | `remote-localhost__8081-copilot` | +| **Vendor** | same as session type | `remote-localhost__8081-copilot` | +| **Display name** | `configuredName \|\| "${displayName} (${address})"` | `dev-box` | + +### Four Registrations Per Agent + +1. **Chat session contribution** - via `IChatSessionsService.registerChatSessionContribution()`: + ```typescript + { type: sessionType, name: agentId, displayName, canDelegate: true, requiresCustomModels: true } + ``` + +2. **Session list controller** - `AgentHostSessionListController` handles the + sidebar session list. Lists sessions via `connection.listSessions()`, listens + for `notify/sessionAdded` and `notify/sessionRemoved` notifications. + +3. **Session handler** - `AgentHostSessionHandler` implements + `IChatSessionContentProvider`, bridging the agent host protocol to chat UI + progress events. Also registers a _dynamic chat agent_ via + `IChatAgentService.registerDynamicAgent()`. + +4. **Language model provider** - `AgentHostLanguageModelProvider` registers + models under the vendor descriptor. Model IDs are prefixed with the session + type (e.g., `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). + +## URI Conventions + +| Context | Scheme | Format | Example | +|---------|--------|--------|---------| +| New session resource | `` | `:/untitled-` | `remote-localhost__8081-copilot:/untitled-abc` | +| Existing session | `` | `:/` | `remote-localhost__8081-copilot:/abc-123` | +| Backend session state | `` | `:/` | `copilot:/abc-123` | +| Root state subscription | (string) | `agenthost:/root` | - | +| Remote filesystem | `agenthost` | `agenthost:///` | `agenthost://localhost__8081/home/user/project` | +| Language model ID | - | `:` | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | + +### Key distinction: session resource vs backend session URI + +- The **session resource** URI uses the session type as its scheme + (e.g., `remote-localhost__8081-copilot:/untitled-abc`). This is the URI visible to + the chat UI and session management. +- The **backend session** URI uses the provider as its scheme + (e.g., `copilot:/abc-123`). This is sent over the agent host protocol to the + server. The `AgentSession.uri(provider, rawId)` helper creates these. + +The `AgentHostSessionHandler` translates between the two: +```typescript +private _resolveSessionUri(sessionResource: URI): URI { + const rawId = sessionResource.path.substring(1); + return AgentSession.uri(this._config.provider, rawId); +} +``` + +## Session Creation Flow + +### 1. User Selects a Remote Workspace + +In the `WorkspacePicker`, the user clicks **"Browse Remotes..."**, selects a +remote host, then picks a folder on the remote filesystem. This produces a +`SessionWorkspace` with an `agenthost://` URI: + +``` +agenthost://localhost__8081/home/user/myproject + ↑ authority ↑ remote filesystem path +``` + +### 2. Session Target Resolution + +`NewChatWidget._createNewSession()` detects `project.isRemoteAgentHost` and +resolves the matching session type via `getRemoteAgentHostSessionTarget()` +(defined in `remoteAgentHost.contribution.ts`): + +```typescript +// authority "localhost__8081" → find connection → "remote-localhost__8081-copilot" +const target = getRemoteAgentHostSessionTarget(connections, authority); +``` + +### 3. Resource URI Generation + +`getResourceForNewChatSession()` creates the session resource: + +```typescript +URI.from({ scheme: target, path: `/untitled-${generateUuid()}` }) +// → remote-localhost__8081-copilot:/untitled-abc-123 +``` + +### 4. Session Object Creation + +`SessionsManagementService.createNewSessionForTarget()` creates an +`AgentHostNewSession` (when the `agentHost` option is set). This is a +lightweight `INewSession` that supports local model and mode pickers but +skips isolation mode, branch, and cloud option groups. +The project URI is set on the session, making it available as +`activeSessionItem.repository`. + +### 5. Backend Session Creation (Deferred) + +`AgentHostSessionHandler` defers backend session creation until the first turn +(for "untitled" sessions), so the user-selected model is available: + +```typescript +const session = await connection.createSession({ + model: rawModelId, + provider: 'copilot', + workingDirectory: '/home/user/myproject', // from activeSession.repository.path +}); +``` + +### 6. Working Directory Resolution + +The `resolveWorkingDirectory` callback in `RemoteAgentHostContribution` reads +the active session's repository URI path: + +```typescript +const resolveWorkingDirectory = (resourceKey: string): string | undefined => { + const activeSessionItem = this._sessionsManagementService.getActiveSession(); + if (activeSessionItem?.repository) { + return activeSessionItem.repository.path; + // For agenthost://authority/home/user/project → "/home/user/project" + } + return undefined; +}; +``` + +## Turn Handling + +When the user sends a message, `AgentHostSessionHandler._handleTurn()`: + +1. Converts variable entries to `IAgentAttachment[]` (file, directory, selection) +2. Dispatches `session/modelChanged` if the model differs from current +3. Dispatches `session/turnStarted` with the user message + attachments +4. Listens to `SessionClientState.onDidChangeSessionState` and translates + the `activeTurn` state changes into `IChatProgress[]` events: + +| Server State | Chat Progress | +|-------------|---------------| +| `streamingText` | `markdownContent` | +| `reasoning` | `thinking` | +| `toolCalls` (new) | `ChatToolInvocation` created | +| `toolCalls` (completed) | `ChatToolInvocation` finalized | +| `pendingPermissions` | `awaitConfirmation()` prompt | + +5. On cancellation, dispatches `session/turnCancelled` + +## Filesystem Provider + +`AgentHostFileSystemProvider` is a read-only `IFileSystemProvider` registered +under the `agenthost` scheme. It proxies `stat` and `readdir` calls through +`connection.browseDirectory(uri)` RPC. + +- The URI authority identifies the remote connection (sanitized address) +- The URI path is the remote filesystem path +- Authority-to-address mappings are registered by `RemoteAgentHostContribution` + via `registerAuthority(authority, address)` + +## Data Flow Diagram + +``` +Settings (chat.remoteAgentHosts) + │ + ▼ +RemoteAgentHostService (WebSocket connections) + │ + ▼ +RemoteAgentHostContribution + │ + ├─► subscribe(ROOT_STATE_URI) → IRootState.agents + │ │ + │ ▼ + │ _registerAgent() for each copilot agent: + │ ├─► registerChatSessionContribution() + │ ├─► registerChatSessionItemController() + │ ├─► registerChatSessionContentProvider() + │ └─► registerLanguageModelProvider() + │ + └─► registerProvider(AGENT_HOST_FS_SCHEME, fsProvider) + +User picks remote workspace in WorkspacePicker + │ + ▼ +NewChatWidget._createNewSession(project) + │ target = getRemoteAgentHostSessionTarget(connections, authority) + ▼ +SessionsManagementService.createNewSessionForTarget() + │ creates AgentHostNewSession + ▼ +User sends message + │ + ▼ +AgentHostSessionHandler._handleTurn() + │ resolves working directory + │ creates backend session (if untitled) + │ dispatches session/turnStarted + ▼ +connection ← streams state changes → IChatProgress[] +``` diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts index 4b6b21adaea..97ec8078107 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts @@ -33,7 +33,8 @@ export const AGENT_HOST_FS_SCHEME = 'agenthost'; * Build an agenthost URI for a given address and path. */ export function agentHostUri(authority: string, path: string): URI { - return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: path || '/' }); + const normalizedPath = !path ? '/' : path.startsWith('/') ? path : `/${path}`; + return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: normalizedPath }); } /** diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index b33e9488bd1..545dbb83d16 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -15,9 +15,10 @@ import { type AgentProvider, type IAgentConnection } from '../../../../platform/ import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js'; import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; -import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; @@ -25,22 +26,50 @@ import { AgentHostSessionListController } from '../../../../workbench/contrib/ch import { ISessionsManagementService } from '../../../contrib/sessions/browser/sessionsManagementService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { AGENT_HOST_FS_SCHEME, AgentHostFileSystemProvider } from './agentHostFileSystemProvider.js'; +import * as nls from '../../../../nls.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; /** * Encode a remote address into an identifier that is safe for use in * both URI schemes and URI authorities, and is collision-free. * - * If the address contains only alphanumeric characters it is returned as-is. - * Otherwise it is url-safe base64-encoded (no padding) to guarantee the - * result contains only `[A-Za-z0-9_-]`. + * Three tiers: + * 1. Purely alphanumeric addresses are returned as-is. + * 2. "Normal" addresses containing only `[a-zA-Z0-9.:-]` get colons + * replaced with `__` (double underscore) for human readability. + * Addresses containing `_` skip this tier to keep the encoding + * collision-free (`__` can only appear from colon replacement). + * 3. Everything else is url-safe base64-encoded with a `b64-` prefix. */ export function agentHostAuthority(address: string): string { if (/^[a-zA-Z0-9]+$/.test(address)) { return address; } + if (/^[a-zA-Z0-9.:\-]+$/.test(address)) { + return address.replaceAll(':', '__'); + } return 'b64-' + encodeBase64(VSBuffer.fromString(address), false, true); } +/** + * Given a sanitized URI authority, resolves the corresponding agent host + * session target string by looking up the matching connection. + * + * Returns `undefined` if no connection matches the authority. + */ +export function getRemoteAgentHostSessionTarget( + connections: readonly IRemoteAgentHostConnectionInfo[], + authority: string, +): AgentSessionTarget | undefined { + for (const conn of connections) { + if (agentHostAuthority(conn.address) === authority) { + return `remote-${agentHostAuthority(conn.address)}-copilot`; + } + } + return undefined; +} + /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { readonly store = this._register(new DisposableStore()); @@ -103,8 +132,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc })); // Push auth token whenever the default account or sessions change - this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._pushAuthTokenToAll())); - this._register(this._authenticationService.onDidChangeSessions(() => this._pushAuthTokenToAll())); + this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._authenticateAllConnections())); + this._register(this._authenticationService.onDidChangeSessions(() => this._authenticateAllConnections())); // Initial setup for already-connected remotes this._reconcileConnections(); @@ -180,8 +209,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._logService.error(`[RemoteAgentHost] Failed to subscribe to root state for ${address}`, err); }); - // Push auth token to this new connection - this._pushAuthToken(connection); + // Authenticate with this new connection + this._authenticateWithConnection(connection); } private _handleRootStateChange(address: string, connection: IAgentConnection, rootState: IRootState): void { @@ -283,6 +312,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, + resolveAuthentication: () => this._resolveAuthenticationInteractively(connection), })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -299,30 +329,93 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._logService.info(`[RemoteAgentHost] Registered agent ${agent.provider} from ${address} as ${sessionType}`); } - private _pushAuthTokenToAll(): void { + private _authenticateAllConnections(): void { for (const address of this._connections.keys()) { const connection = this._remoteAgentHostService.getConnection(address); if (connection) { - this._pushAuthToken(connection); + this._authenticateWithConnection(connection); } } } - private async _pushAuthToken(connection: IAgentConnection): Promise { + /** + * Discover auth requirements from the connection's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithConnection(connection: IAgentConnection): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); + await connection.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Failed to authenticate with connection', err); + } + } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); + if (!providerId) { + this._logService.trace(`[RemoteAgentHost] No auth provider found for server: ${server}`); + continue; } - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await connection.setAuthToken(session.accessToken); + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; } - } catch { - // best-effort } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(connection: IAgentConnection): Promise { + try { + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const resourceUri = URI.parse(resource.resource); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); + if (!providerId) { + continue; + } + + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await connection.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Interactive authentication failed', err); + } + return false; } private _traceIpc(address: string, method: string, data?: unknown): void { @@ -355,3 +448,23 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + properties: { + [RemoteAgentHostsSettingId]: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, + name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, + connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, + }, + required: ['address', 'name'], + }, + description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), + default: [], + tags: ['experimental', 'advanced'], + }, + }, +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts index b4396b54d59..6761d7455c3 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts @@ -25,6 +25,13 @@ suite('AgentHostFileSystemProvider - URI helpers', () => { assert.strictEqual(uri.path, '/'); }); + test('agentHostUri normalizes path without leading slash', () => { + const uri = agentHostUri('localhost:8081', 'home/user/project'); + assert.strictEqual(uri.scheme, AGENT_HOST_FS_SCHEME); + assert.strictEqual(uri.authority, 'localhost:8081'); + assert.strictEqual(uri.path, '/home/user/project'); + }); + test('agentHostRemotePath extracts the path component', () => { const uri = URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority: 'host', path: '/some/path' }); assert.strictEqual(agentHostRemotePath(uri), '/some/path'); @@ -45,13 +52,26 @@ suite('AgentHostAuthority - encoding', () => { assert.strictEqual(agentHostAuthority('localhost'), 'localhost'); }); - test('address with special characters is base64-encoded', () => { - const authority = agentHostAuthority('localhost:8081'); - assert.ok(authority.startsWith('b64-')); + test('normal host:port address uses human-readable encoding', () => { + assert.strictEqual(agentHostAuthority('localhost:8081'), 'localhost__8081'); + assert.strictEqual(agentHostAuthority('192.168.1.1:8080'), '192.168.1.1__8080'); + assert.strictEqual(agentHostAuthority('my-host:9090'), 'my-host__9090'); + assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80'); + }); + + test('address with underscore falls through to base64', () => { + const authority = agentHostAuthority('host_name:8080'); + assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`); + }); + + test('address with exotic characters is base64-encoded', () => { + assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-')); + assert.ok(agentHostAuthority('host with spaces').startsWith('b64-')); + assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-')); }); test('different addresses produce different authorities', () => { - const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80']; + const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080']; const results = cases.map(agentHostAuthority); const unique = new Set(results); assert.strictEqual(unique.size, cases.length, 'all authorities must be unique'); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index b9f14273091..87dde988060 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -77,18 +77,4 @@ flex-shrink: 0; } -/* Changes summary */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes { - display: flex; - align-items: center; - flex-shrink: 0; - gap: 3px; -} -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-added { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-removed { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 1666be70127..4c96289ccb3 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -93,10 +93,5 @@ .agent-sessions-control-container { flex: 1; overflow: hidden; - - /* Override section header padding to align with dot indicators */ - .agent-session-section { - padding-left: 12px; - } } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 52bd4417115..c543f12d05f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -10,11 +10,15 @@ import { localize, localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AgentSessionSection, IAgentSessionSection, isAgentSessionSection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { NewChatViewPane, SessionsViewId as NewChatViewId } from '../../chat/browser/newChatViewPane.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -45,6 +49,38 @@ const agentSessionsViewDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); -registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); - registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); + +registerAction2(class NewSessionForRepositoryAction extends Action2 { + + constructor() { + super({ + id: 'agentSessionSection.newSession', + title: localize2('newSessionForRepo', "New Session"), + icon: Codicon.newSession, + menu: [{ + id: MenuId.AgentSessionSectionToolbar, + group: 'navigation', + order: 0, + when: ChatContextKeys.agentSessionSection.isEqualTo(AgentSessionSection.Repository), + }] + }); + } + + async run(accessor: ServicesAccessor, context?: IAgentSessionSection): Promise { + if (!context || !isAgentSessionSection(context) || context.sessions.length === 0) { + return; + } + + const sessionsManagementService = accessor.get(ISessionsManagementService); + const viewsService = accessor.get(IViewsService); + + const repositoryUri = sessionsManagementService.getSessionRepositoryUri(context.sessions[0]); + sessionsManagementService.openNewSessionView(); + + const view = await viewsService.openView(NewChatViewId, true); + if (view instanceof NewChatViewPane && repositoryUri) { + view.setProject(repositoryUri); + } + } +}); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index f243fead347..c2b35c960f7 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -20,8 +20,8 @@ import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../. import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { INewSession, CopilotCLISession, RemoteNewSession } from '../../chat/browser/newSession.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { INewSession, CopilotCLISession, RemoteNewSession, AgentHostNewSession } from '../../chat/browser/newSession.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { isBuiltinChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; @@ -83,11 +83,16 @@ export interface ISessionsManagementService { */ openNewSessionView(): void; + /** + * Returns the repository URI for the given session, if available. + */ + getSessionRepositoryUri(session: IAgentSession): URI | undefined; + /** * Create a pending session object for the given target type. * Local sessions collect options locally; remote sessions notify the extension. */ - createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise; + createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise; /** * Open a new session, apply options, and send the initial request. @@ -260,14 +265,16 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.instantiationService.invokeFunction(openSessionDefault, existingSession, openOptions); } - async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise { + async createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise { if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } let newSession: INewSession; if (target === AgentSessionProviders.Background) { - newSession = this.instantiationService.createInstance(CopilotCLISession, sessionResource, defaultRepoUri); + newSession = this.instantiationService.createInstance(CopilotCLISession, sessionResource, options?.defaultRepoUri); + } else if (options?.agentHost) { + newSession = new AgentHostNewSession(sessionResource, target); } else { newSession = this.instantiationService.createInstance(RemoteNewSession, sessionResource, target); } @@ -396,12 +403,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (selectedOptions && selectedOptions.size > 0) { const contributedSession = model.contributedChatSession; if (contributedSession) { - const initialSessionOptions = [...selectedOptions.entries()].map( - ([optionId, value]) => ({ optionId, value }) - ); model.setContributedChatSession({ ...contributedSession, - initialSessionOptions, + initialSessionOptions: selectedOptions, }); } } @@ -460,6 +464,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.isNewChatSessionContext.set(true); } + getSessionRepositoryUri(session: IAgentSession): URI | undefined { + const [repositoryUri] = this.getRepositoryFromMetadata(session); + return repositoryUri; + } + private setActiveSession(session: IAgentSession | INewSession | undefined): void { let activeSessionItem: IActiveSessionItem | undefined; if (session) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index b8096dff925..774577eeb23 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -19,7 +19,7 @@ import { IMenuService, MenuId, MenuRegistry, SubmenuItemAction } from '../../../ import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { IMarshalledAgentSessionContext, getAgentChangesSummary, hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IMarshalledAgentSessionContext } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; @@ -128,10 +128,8 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const label = this._getActiveSessionLabel(); const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); - const changesSummary = this._getChangesSummary(); - // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${changesSummary?.insertions ?? ''}|${changesSummary?.deletions ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -176,25 +174,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { centerGroup.appendChild(repoEl); } - // Changes summary shown next to the repo - if (changesSummary) { - const separator2 = $('span.agent-sessions-titlebar-separator'); - separator2.textContent = '\u00B7'; - centerGroup.appendChild(separator2); - - const changesEl = $('span.agent-sessions-titlebar-changes'); - - const addedEl = $('span.agent-sessions-titlebar-changes-added'); - addedEl.textContent = `+${changesSummary.insertions}`; - changesEl.appendChild(addedEl); - - const removedEl = $('span.agent-sessions-titlebar-changes-removed'); - removedEl.textContent = `-${changesSummary.deletions}`; - changesEl.appendChild(removedEl); - - centerGroup.appendChild(changesEl); - } - sessionPill.appendChild(centerGroup); // Click handler on pill - show sessions picker @@ -363,24 +342,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { menu.dispose(); } - /** - * Get the changes summary for the active session. - */ - private _getChangesSummary(): { insertions: number; deletions: number } | undefined { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { - return undefined; - } - - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - const changes = agentSession?.changes; - if (!changes || !hasValidDiff(changes)) { - return undefined; - } - - return getAgentChangesSummary(changes); - } - private _showSessionsPicker(): void { const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource, openOptions) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 86c6a2cc0ba..66f102e9d76 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -8,7 +8,7 @@ import * as DOM from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun } from '../../../../base/common/observable.js'; -import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorsVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -23,8 +23,8 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; -import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionProviders, isAgentHostTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -41,16 +41,21 @@ import { IHostService } from '../../../../workbench/services/host/browser/host.j const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); -const IsGroupedByRepositoryContext = new RawContextKey('sessionsView.isGroupedByRepository', false); +const SessionsViewFilterOptionsSubMenu = new MenuId('AgentSessionsViewFilterOptionsSubMenu'); +const SessionsViewGroupingContext = new RawContextKey('sessionsView.grouping', AgentSessionsGrouping.Repository); +const SessionsViewSortingContext = new RawContextKey('sessionsView.sorting', AgentSessionsSorting.Created); const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; +const SORTING_STORAGE_KEY = 'agentSessions.sorting'; export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; - private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Date; - private isGroupedByRepoKey: ReturnType | undefined; + private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Repository; + private currentSorting: AgentSessionsSorting = AgentSessionsSorting.Created; + private groupingContextKey: IContextKey | undefined; + private sortingContextKey: IContextKey | undefined; constructor( options: IViewPaneOptions, @@ -71,10 +76,22 @@ export class AgenticSessionsViewPane extends ViewPane { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); // Restore persisted grouping - const stored = this.storageService.get(GROUPING_STORAGE_KEY, StorageScope.PROFILE); - if (stored && Object.values(AgentSessionsGrouping).includes(stored as AgentSessionsGrouping)) { - this.currentGrouping = stored as AgentSessionsGrouping; + const storedGrouping = this.storageService.get(GROUPING_STORAGE_KEY, StorageScope.PROFILE); + if (storedGrouping && Object.values(AgentSessionsGrouping).includes(storedGrouping as AgentSessionsGrouping)) { + this.currentGrouping = storedGrouping as AgentSessionsGrouping; } + + // Restore persisted sorting + const storedSorting = this.storageService.get(SORTING_STORAGE_KEY, StorageScope.PROFILE); + if (storedSorting && Object.values(AgentSessionsSorting).includes(storedSorting as AgentSessionsSorting)) { + this.currentSorting = storedSorting as AgentSessionsSorting; + } + + // Ensure context keys reflect restored state immediately + this.groupingContextKey = SessionsViewGroupingContext.bindTo(contextKeyService); + this.groupingContextKey.set(this.currentGrouping); + this.sortingContextKey = SessionsViewSortingContext.bindTo(contextKeyService); + this.sortingContextKey.set(this.currentSorting); } protected override renderBody(parent: HTMLElement): void { @@ -101,15 +118,13 @@ export class AgenticSessionsViewPane extends ViewPane { private createControls(parent: HTMLElement): void { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); - // Track grouping state via context key for the toggle button - const isGroupedByRepoKey = this.isGroupedByRepoKey = IsGroupedByRepositoryContext.bindTo(this.contextKeyService); - isGroupedByRepoKey.set(this.currentGrouping === AgentSessionsGrouping.Repository); - - // Sessions Filter (actions go to view title bar via menu registration) + // Sessions Filter (actions go to the nested filter submenu) const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { - filterMenuId: SessionsViewFilterSubMenu, + filterMenuId: SessionsViewFilterOptionsSubMenu, groupResults: () => this.currentGrouping, + sortResults: () => this.currentSorting, allowedProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], + overrideExclude: session => isAgentHostTarget(session.providerType) ? false : undefined, providerLabelOverrides: new Map([ [AgentSessionProviders.Background, localize('chat.session.providerLabel.background', "Copilot CLI")], ]), @@ -229,15 +244,26 @@ export class AgenticSessionsViewPane extends ViewPane { this.sessionsControl?.openFind(); } - toggleGroupByRepository(): void { - if (this.currentGrouping === AgentSessionsGrouping.Repository) { - this.currentGrouping = AgentSessionsGrouping.Date; - } else { - this.currentGrouping = AgentSessionsGrouping.Repository; + setGrouping(grouping: AgentSessionsGrouping): void { + if (this.currentGrouping === grouping) { + return; } + this.currentGrouping = grouping; this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); - this.isGroupedByRepoKey?.set(this.currentGrouping === AgentSessionsGrouping.Repository); + this.groupingContextKey?.set(this.currentGrouping); + this.sessionsControl?.resetSectionCollapseState(); + this.sessionsControl?.update(); + } + + setSorting(sorting: AgentSessionsSorting): void { + if (this.currentSorting === sorting) { + return; + } + + this.currentSorting = sorting; + this.storageService.store(SORTING_STORAGE_KEY, this.currentSorting, StorageScope.PROFILE, StorageTarget.USER); + this.sortingContextKey?.set(this.currentSorting); this.sessionsControl?.update(); } } @@ -281,22 +307,30 @@ MenuRegistry.appendMenuItem(MenuId.ViewTitle, { title: localize2('filterAgentSessions', "Filter Sessions"), group: 'navigation', order: 3, - icon: Codicon.filter, + icon: Codicon.settings, when: ContextKeyExpr.equals('view', SessionsViewId) } satisfies ISubmenuItem); -registerAction2(class GroupByRepositoryAction extends Action2 { +// Nest the filter toggles (providers, statuses, properties, reset) inside a "Filter" submenu +MenuRegistry.appendMenuItem(SessionsViewFilterSubMenu, { + submenu: SessionsViewFilterOptionsSubMenu, + title: localize2('filter', "Filter"), + group: '1_filter', + order: 0, +} satisfies ISubmenuItem); + +// Sort By: Created Date (radio) +registerAction2(class SortByCreatedAction extends Action2 { constructor() { super({ - id: 'sessionsView.groupByRepository', - title: localize2('groupByRepository', "Group by Repository"), - icon: Codicon.repo, + id: 'sessionsView.sortByCreated', + title: localize2('sortByCreated', "Sort by Created"), category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, AgentSessionsSorting.Created), menu: [{ - id: MenuId.ViewTitle, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext.negate()), + id: SessionsViewFilterSubMenu, + group: '2_sort', + order: 0, }] }); } @@ -304,22 +338,22 @@ registerAction2(class GroupByRepositoryAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleGroupByRepository(); + view?.setSorting(AgentSessionsSorting.Created); } }); -registerAction2(class GroupByDateAction extends Action2 { +// Sort By: Updated Date (radio) +registerAction2(class SortByUpdatedAction extends Action2 { constructor() { super({ - id: 'sessionsView.groupByDate', - title: localize2('groupByDate', "Group by Date"), - icon: Codicon.history, + id: 'sessionsView.sortByUpdated', + title: localize2('sortByUpdated', "Sort by Updated"), category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, AgentSessionsSorting.Updated), menu: [{ - id: MenuId.ViewTitle, - group: 'navigation', + id: SessionsViewFilterSubMenu, + group: '2_sort', order: 1, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext), }] }); } @@ -327,7 +361,53 @@ registerAction2(class GroupByDateAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleGroupByRepository(); + view?.setSorting(AgentSessionsSorting.Updated); + } +}); + +// Group By: Project (radio) +registerAction2(class GroupByProjectAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByProject', + title: localize2('groupByProject', "Group by Project"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_group', + order: 0, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(AgentSessionsGrouping.Repository); + } +}); + +// Group By: Time (radio) +registerAction2(class GroupByTimeAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByTime', + title: localize2('groupByTime', "Group by Time"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Date), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_group', + order: 1, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(AgentSessionsGrouping.Date); } }); diff --git a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts index fe7fe1c2b41..aa5a5ba3a44 100644 --- a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts +++ b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts @@ -8,9 +8,16 @@ import { IGitRepository } from '../../../../workbench/contrib/git/common/gitServ export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; +/** + * URI scheme for agent host remote filesystems. + * Must match {@link AGENT_HOST_FS_SCHEME} in `agentHostFileSystemProvider.ts` + * (which lives in the `browser` layer and cannot be imported here). + */ +export const AGENT_HOST_SCHEME = 'agenthost'; + /** * Represents a workspace (folder or repository) for a session. - * The workspace type (folder vs repo) is derived from the URI scheme. + * The workspace type (folder vs repo vs remote agent host) is derived from the URI scheme. */ export class SessionWorkspace { @@ -24,7 +31,7 @@ export class SessionWorkspace { /** Whether this is a local folder workspace. */ get isFolder(): boolean { - return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME; + return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME && this.uri.scheme !== AGENT_HOST_SCHEME; } /** Whether this is a remote repository workspace. */ @@ -32,6 +39,11 @@ export class SessionWorkspace { return this.uri.scheme === GITHUB_REMOTE_FILE_SCHEME; } + /** Whether this is a remote agent host workspace. */ + get isRemoteAgentHost(): boolean { + return this.uri.scheme === AGENT_HOST_SCHEME; + } + /** Returns a new SessionWorkspace with the repository updated. */ withRepository(repository: IGitRepository | undefined): SessionWorkspace { return new SessionWorkspace(this.uri, repository); diff --git a/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts new file mode 100644 index 00000000000..8b9883f1b7b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AGENT_HOST_SCHEME, GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../common/sessionWorkspace.js'; +import type { IGitRepository } from '../../../../../workbench/contrib/git/common/gitService.js'; + +suite('SessionWorkspace', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('local folder is classified as isFolder', () => { + const ws = new SessionWorkspace(URI.file('/home/user/project')); + assert.strictEqual(ws.isFolder, true); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('GitHub repo is classified as isRepo', () => { + const ws = new SessionWorkspace(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, true); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('agent host URI is classified as isRemoteAgentHost', () => { + const ws = new SessionWorkspace(URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/home/user/project' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, true); + }); + + test('withRepository preserves URI and updates repository', () => { + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/proj' }); + const ws = new SessionWorkspace(uri); + const repo = { rootUri: URI.file('/repo') } as IGitRepository; + const ws2 = ws.withRepository(repo); + assert.strictEqual(ws2.uri.toString(), uri.toString()); + assert.strictEqual(ws2.isRemoteAgentHost, true); + assert.strictEqual(ws2.repository, repo); + }); +}); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 96d17055897..44668fe89ec 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -20,10 +20,13 @@ import { IPathService } from '../../../../workbench/services/path/common/pathSer import { Menus } from '../../../browser/menus.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; + +const SessionsTerminalViewVisibleContext = new RawContextKey('sessionsTerminalViewVisible', false); /** * Returns the cwd URI for the given session: worktree or repository path for @@ -55,9 +58,21 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, @ILogService private readonly _logService: ILogService, @IPathService private readonly _pathService: IPathService, + @IViewsService viewsService: IViewsService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + // Track whether the terminal view is visible so the titlebar toggle + // button shows the correct checked state. + const terminalViewVisible = SessionsTerminalViewVisibleContext.bindTo(contextKeyService); + terminalViewVisible.set(viewsService.isViewVisible(TERMINAL_VIEW_ID)); + this._register(viewsService.onDidChangeViewVisibility(e => { + if (e.id === TERMINAL_VIEW_ID) { + terminalViewVisible.set(e.visible); + } + })); + // React to active session changes — use worktree/repo for background sessions, home dir otherwise this._register(autorun(reader => { const session = this._sessionsManagementService.activeSession.read(reader); @@ -285,6 +300,10 @@ class OpenSessionInTerminalAction extends Action2 { id: 'agentSession.openInTerminal', title: localize2('openInTerminal', "Open Terminal"), icon: Codicon.terminal, + toggled: { + condition: SessionsTerminalViewVisibleContext, + title: localize('hideTerminal', "Hide Terminal"), + }, menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', @@ -295,10 +314,21 @@ class OpenSessionInTerminalAction extends Action2 { } override async run(_accessor: ServicesAccessor): Promise { + const layoutService = _accessor.get(IWorkbenchLayoutService); + const viewsService = _accessor.get(IViewsService); + + // Toggle: if panel is visible and the terminal view is active, hide it. + // If the panel is visible but showing another view, open the terminal instead. + if (layoutService.isVisible(Parts.PANEL_PART)) { + if (viewsService.isViewVisible(TERMINAL_VIEW_ID)) { + layoutService.setPartHidden(true, Parts.PANEL_PART); + return; + } + } + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); const sessionsManagementService = _accessor.get(ISessionsManagementService); const pathService = _accessor.get(IPathService); - const viewsService = _accessor.get(IViewsService); const activeSession = sessionsManagementService.activeSession.get(); const cwd = getSessionCwd(activeSession) ?? await pathService.userHome(); 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 b171cf31cc2..927730c8c9d 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -21,6 +21,9 @@ import { IActiveSessionItem, ISessionsManagementService } from '../../../session import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; const HOME_DIR = URI.file('/home/user'); @@ -199,6 +202,13 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(IPathService, new TestPathService(HOME_DIR)); + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + + instantiationService.stub(IViewsService, new class extends mock() { + override isViewVisible(): boolean { return false; } + override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event; + }); + contribution = store.add(instantiationService.createInstance(SessionsTerminalContribution)); }); diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index f22ddd122c5..29634287b09 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -90,7 +90,6 @@ class SessionsWelcomeOverlay extends Disposable { dialogTitle: this.chatEntitlementService.anonymous ? localize('sessions.startUsingSessions', "Start using Sessions") : localize('sessions.signinRequired', "Sign in to use Sessions"), - dialogHideSkip: true }); if (success) { @@ -125,7 +124,7 @@ class SessionsWelcomeOverlay extends Disposable { } } -class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { +export class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsWelcome'; @@ -170,31 +169,42 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri if (this._needsChatSetup()) { this.showOverlay(); } else { - this.watchForRegressions(); + this.watchEntitlementState(); } } - private watchForRegressions(): void { - let wasComplete = !this._needsChatSetup(); + /** + * Watches entitlement and sentiment observables after setup has already + * completed. If the user's state changes such that setup is needed again + * (e.g. extension uninstalled/disabled), shows the welcome overlay. + * + * {@link ChatEntitlement.Unknown} is intentionally ignored here: it is + * almost always a transient state caused by a stale OAuth token being + * refreshed after an update. A genuine sign-out will be caught on the + * next app launch via the initial {@link showOverlayIfNeeded} check. + */ + private watchEntitlementState(): void { + let setupComplete = !this._needsChatSetup(false); this.watcherRef.value = autorun(reader => { this.chatEntitlementService.sentimentObs.read(reader); this.chatEntitlementService.entitlementObs.read(reader); - const needsSetup = this._needsChatSetup(); - if (wasComplete && needsSetup) { + const needsSetup = this._needsChatSetup(false); + if (setupComplete && needsSetup) { this.showOverlay(); } - wasComplete = !needsSetup; + setupComplete = !needsSetup; }); } - private _needsChatSetup(): boolean { + private _needsChatSetup(includeUnknown: boolean = true): boolean { const { sentiment, entitlement } = this.chatEntitlementService; if ( !sentiment?.installed || // Extension not installed: run setup to install sentiment?.disabled || // Extension disabled: run setup to enable entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up ( + includeUnknown && entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up !this.chatEntitlementService.anonymous // unless anonymous access is enabled ) @@ -232,7 +242,7 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); overlay.dismiss(); this.overlayRef.clear(); - this.watchForRegressions(); + this.watchEntitlementState(); } })); } diff --git a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts new file mode 100644 index 00000000000..7105288c548 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; +import { SessionsWelcomeContribution } from '../../browser/welcome.contribution.js'; + +const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; + +class MockChatEntitlementService implements Partial { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeEntitlement = Event.None; + readonly onDidChangeSentiment = Event.None; + readonly onDidChangeAnonymous = Event.None; + readonly onDidChangeQuotaExceeded = Event.None; + readonly onDidChangeQuotaRemaining = Event.None; + + readonly entitlementObs: ISettableObservable = observableValue('entitlement', ChatEntitlement.Free); + readonly sentimentObs: ISettableObservable = observableValue('sentiment', { installed: true } as IChatSentiment); + readonly anonymousObs: ISettableObservable = observableValue('anonymous', false); + + readonly organisations = undefined; + readonly isInternal = false; + readonly sku = undefined; + readonly copilotTrackingId = undefined; + readonly quotas = {}; + readonly previewFeaturesDisabled = false; + + get entitlement(): ChatEntitlement { return this.entitlementObs.get(); } + get sentiment(): IChatSentiment { return this.sentimentObs.get(); } + get anonymous(): boolean { return this.anonymousObs.get(); } + + update(): Promise { return Promise.resolve(); } + markAnonymousRateLimited(): void { } +} + +suite('SessionsWelcomeContribution', () => { + + const disposables = new DisposableStore(); + let instantiationService: TestInstantiationService; + let mockEntitlementService: MockChatEntitlementService; + + setup(() => { + instantiationService = workbenchInstantiationService(undefined, disposables); + mockEntitlementService = new MockChatEntitlementService(); + instantiationService.stub(IChatEntitlementService, mockEntitlementService as unknown as IChatEntitlementService); + + // Ensure product has a defaultChatAgent so the contribution activates + const productService = instantiationService.get(IProductService); + instantiationService.stub(IProductService, { + ...productService, + defaultChatAgent: { ...productService.defaultChatAgent, chatExtensionId: 'test.chat' } + } as IProductService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function markReturningUser(): void { + const storageService = instantiationService.get(IStorageService); + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + function isOverlayVisible(): boolean { + const contextKeyService = instantiationService.get(IContextKeyService); + return SessionsWelcomeVisibleContext.getValue(contextKeyService) === true; + } + + test('first launch shows overlay', () => { + // First launch with no entitlement — should show overlay + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true); + }); + + test('returning user with valid entitlement does not show overlay', () => { + markReturningUser(); + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false); + }); + + test('returning user: transient Unknown entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate transient Unknown (stale token → 401) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for transient Unknown'); + + // Simulate recovery (token refreshed → entitlement restored) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should remain hidden after recovery'); + }); + + test('returning user: transient Unresolved entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Pro, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate Unresolved (intermediate state during account resolution) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unresolved, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for Unresolved'); + }); + + test('returning user: extension uninstalled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate extension being uninstalled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is uninstalled'); + }); + + test('returning user: extension disabled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate extension being disabled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: true, disabled: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is disabled'); + }); + + test('overlay dismisses when setup completes', () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true, 'should show on first launch'); + + // Simulate completing setup + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should dismiss after setup completes'); + }); + + test('returning user: entitlement going to Available DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Available means user can sign up for free — this is a real state, + // not transient, so the overlay should show + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Available, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay for Available entitlement'); + }); +}); diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts index b72a879cb7d..5c2692372ff 100644 --- a/src/vs/sessions/electron-browser/parts/titlebarPart.ts +++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts @@ -10,6 +10,7 @@ import { IContextKeyService } from '../../../platform/contextkey/common/contextk import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { useWindowControlsOverlay } from '../../../platform/window/common/window.js'; @@ -20,6 +21,7 @@ import { IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titleba import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; import { TitlebarPart, TitleService } from '../../browser/parts/titlebarPart.js'; +import { isMacintosh } from '../../../base/common/platform.js'; export class NativeTitlebarPart extends TitlebarPart { @@ -37,6 +39,7 @@ export class NativeTitlebarPart extends TitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService private readonly productService: IProductService, @INativeHostService private readonly nativeHostService: INativeHostService, ) { super(id, targetWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); @@ -44,6 +47,24 @@ export class NativeTitlebarPart extends TitlebarPart { this.handleWindowsAlwaysOnTop(targetWindow.vscodeWindowId, contextKeyService); } + protected override createContentArea(parent: HTMLElement): HTMLElement { + + // Workaround for macOS/Electron bug where the window does not + // appear in the "Windows" menu if the first `document.title` + // matches the BrowserWindow's initial title. + // See: https://github.com/microsoft/vscode/issues/191288 + if (isMacintosh) { + const window = getWindow(this.element); + const nativeTitle = this.productService.nameLong; + if (!window.document.title || window.document.title === nativeTitle) { + window.document.title = `${nativeTitle} \u200b`; + } + window.document.title = nativeTitle; + } + + return super.createContentArea(parent); + } + private async handleWindowsAlwaysOnTop(targetWindowId: number, contextKeyService: IContextKeyService): Promise { const isWindowAlwaysOnTopContext = IsWindowAlwaysOnTopContext.bindTo(contextKeyService); @@ -107,9 +128,10 @@ class MainNativeTitlebarPart extends NativeTitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { - super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } } @@ -130,10 +152,11 @@ class AuxiliaryNativeTitlebarPart extends NativeTitlebarPart implements IAuxilia @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { const id = AuxiliaryNativeTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } override get preventZoom(): boolean { diff --git a/src/vs/sessions/prompts/create-draft-pr.prompt.md b/src/vs/sessions/prompts/create-draft-pr.prompt.md index 4def295b9fc..c2529a264d4 100644 --- a/src/vs/sessions/prompts/create-draft-pr.prompt.md +++ b/src/vs/sessions/prompts/create-draft-pr.prompt.md @@ -5,7 +5,9 @@ description: Create a draft pull request for the current session Use the GitHub MCP server to create a draft pull request — do NOT use the `gh` CLI. -1. Review all changes in the current session -2. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") -3. Write a description covering what changed, why, and anything reviewers should know -4. Create the draft pull request +1. Run the compile and hygiene tasks (fixing any errors) +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. Create the draft pull request diff --git a/src/vs/sessions/prompts/create-pr.prompt.md b/src/vs/sessions/prompts/create-pr.prompt.md index 02208021e3a..4991f4ff582 100644 --- a/src/vs/sessions/prompts/create-pr.prompt.md +++ b/src/vs/sessions/prompts/create-pr.prompt.md @@ -6,7 +6,8 @@ description: Create a pull request for the current session Use the GitHub MCP server to create a pull request — do NOT use the `gh` CLI. 1. Run the compile and hygiene tasks (fixing any errors) -2. Review all changes in the current session -3. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") -4. Write a description covering what changed, why, and anything reviewers should know -5. Create the pull request +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. Create the pull request diff --git a/src/vs/sessions/prompts/merge-changes.prompt.md b/src/vs/sessions/prompts/merge-changes.prompt.md new file mode 100644 index 00000000000..065cb18ad18 --- /dev/null +++ b/src/vs/sessions/prompts/merge-changes.prompt.md @@ -0,0 +1,10 @@ +--- +description: Merge changes from the topic branch to the merge base branch +--- + + +Merge changes from the topic branch to the merge base branch. +The context block appended to the prompt contains the source and target branch information. + +1. If there are any uncommitted changes, use the `/commit` skill to commit them +2. Merge the topic branch into the merge base branch. If there are any merge conflicts, resolve them and commit the merge. When in doubt on how to resolve a merge conflict, ask the user for guidance on how to proceed diff --git a/src/vs/sessions/prompts/update-pr.prompt.md b/src/vs/sessions/prompts/update-pr.prompt.md new file mode 100644 index 00000000000..22ecf6ccf52 --- /dev/null +++ b/src/vs/sessions/prompts/update-pr.prompt.md @@ -0,0 +1,13 @@ +--- +description: Update the pull request for the current session +--- + + +Update the existing pull request for the current session. +The context block appended to the prompt contains the pull request information. + +1. Check whether the pull request has any commits that are not yet present on the current branch (incoming changes). If there are any incoming changes, pull them into the current branch and resolve any merge conflicts +2. Run the compile and hygiene tasks (fixing any errors) +3. If there are any uncommitted changes, use the `/commit` skill to commit them +4. If the outgoing changes introduce significant changes to the pull request, update the pull request title and description to reflect those changes +5. Update the pull request with the new commits and information diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index e4be5c11daf..0202df84fa9 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -212,6 +212,7 @@ import '../workbench/contrib/chat/browser/chat.contribution.js'; import '../workbench/contrib/mcp/browser/mcp.contribution.js'; import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import '../workbench/contrib/chat/browser/contextContrib/chatContext.contribution.js'; +import '../workbench/contrib/imageCarousel/browser/imageCarousel.contribution.js'; // Interactive import '../workbench/contrib/interactive/browser/interactive.contribution.js'; diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index ffcc3b6c194..d0e992b60a8 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -202,13 +202,13 @@ import './browser/layoutActions.js'; import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/chatSessionHeader.js'; import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changes/browser/changesView.contribution.js'; import './contrib/codeReview/browser/codeReview.contributions.js'; import './contrib/files/browser/files.contribution.js'; -import './contrib/git/browser/git.contribution.js'; import './contrib/github/browser/github.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed @@ -221,6 +221,7 @@ import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js'; // Remote Agent Host +import '../platform/agentHost/electron-browser/agentHostService.js'; import '../platform/agentHost/electron-browser/remoteAgentHostService.js'; import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index be32907bc88..68d2ac1ea83 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -157,6 +157,7 @@ import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/chatSessionHeader.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; diff --git a/src/vs/sessions/skills/commit/SKILL.md b/src/vs/sessions/skills/commit/SKILL.md new file mode 100644 index 00000000000..2bd73ac44c9 --- /dev/null +++ b/src/vs/sessions/skills/commit/SKILL.md @@ -0,0 +1,80 @@ +--- +name: commit +description: Commit staged or unstaged changes with an AI-generated commit message that matches the repository's existing commit style. Use when the user asks to 'commit', 'commit changes', 'create a commit', 'save my work', or 'check in code'. +--- + + +# Commit Changes + +Help the user commit code changes with a well-crafted commit message derived from the diff, following the conventions already established in the repository. + +## Guidelines + +- **Never amend existing commits** without asking. +- **Never force-push or push** without explicit user approval. +- **Never skip pre-commit hooks** (do not use `--no-verify`). +- **Never skip signing commits** (do not use `--no-gpg-sign`). +- **Never revert, reset, or discard user changes** unless the user explicitly asked for that. +- Check for obvious secrets or generated artifacts that should not be committed. If something looks risky - ask the user. +- When in doubt about staging, convention, or message content — ask the user. + +## Workflow + +### 1. Discover the repository's commit convention + +Run the following to sample recent commits and the user's own commits: + +``` +# Recent repo commits (for overall style) +git log --oneline -20 + +# User's recent commits (for personal style) +git log --oneline --author="$(git config user.name)" -10 +``` + +Analyse the output to determine the commit message convention used in the repository (e.g. Conventional Commits, Gitmoji, ticket-prefixed, free-form). All generated messages **must** follow the detected convention. + +### 2. Check repository status + +``` +git status --short +``` + +- If there are **no changes** (working tree clean, nothing staged), inform the user and stop. +- If there are **staged changes**, proceed with those and do not stage any unstaged changes. +- If there are **only unstaged changes**, stage everything (`git add -A`), and proceed with those. + +### 3. Generate the commit message + +Obtain the full diff of what will be committed: + +```bash +git diff --cached --stat +git diff --cached +``` + +Using the diff and the commit convention detected in step 1, draft a commit message with: + +- A **subject line** (≤ 72 characters) that summarises the change, following the repository's convention. +- An optional **body** that explains *why* the change was made, only when the diff is non-trivial. +- Reference issue/ticket numbers when they appear in branch names or related context. +- Focus on the intent of the change, not a file-by-file inventory. + +### 4. Commit + +Construct the `git commit` command with the generated message. + +Execute the commit: + +``` +git commit -m "" -m "" +``` + +### 5. Confirm + +After the commit: + +- Run `git status --short` to confirm the commit completed. +- Run `git log --oneline -1` to show the new commit. +- If pre-commit hooks changed files or blocked the commit, summarize exactly what happened. +- If hooks rewrote files after the commit attempt, do not amend automatically. Tell the user what changed and ask whether they want you to stage and commit those follow-up edits. diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index 3daeecaa43f..226ba29adef 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -35,6 +35,7 @@ import { SyncDescriptor } from '../../platform/instantiation/common/descriptors. import { getSingletonServiceDescriptors } from '../../platform/instantiation/common/extensions.js'; import { ServiceIdentifier } from '../../platform/instantiation/common/instantiation.js'; import { IWorkbench } from '../../workbench/browser/web.api.js'; +import { isEqual } from '../../base/common/resources.js'; /** * Mock files pre-seeded in the in-memory file system. These match the @@ -283,14 +284,14 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu })); // Add or update session in list - const existing = this._sessionItems.find(s => s.resource.toString() === key); - let addedOrUpdated: IChatSessionItem | undefined = existing; - if (existing) { - existing.timing.lastRequestStarted = now; - existing.timing.lastRequestEnded = now; + const existingIndex = this._sessionItems.findIndex(s => isEqual(s.resource, resource)); + let addedOrUpdated = existingIndex !== -1 ? { ...this._sessionItems[existingIndex] } : undefined; + if (addedOrUpdated) { + addedOrUpdated.timing = { ...addedOrUpdated.timing, lastRequestStarted: now, lastRequestEnded: now }; if (changes) { - existing.changes = changes; + addedOrUpdated.changes = changes; } + this._sessionItems[existingIndex] = addedOrUpdated; } else { addedOrUpdated = { resource, diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts index aad0abf2054..2c0e0b7875b 100644 --- a/src/vs/workbench/api/browser/mainThreadBrowsers.ts +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -41,11 +41,6 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers this._track(e.editor); } })); - this._register(this.editorService.onDidCloseEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - this._knownBrowsers.deleteAndDispose(e.editor.id); - } - })); this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); // Initial sync @@ -82,12 +77,17 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers // #region Browser tab tracking + private _lastActiveBrowserId: string | undefined = undefined; private async _syncActiveBrowserTab(): Promise { const active = this.editorService.activeEditorPane?.input; + let activeId: string | undefined; if (active instanceof BrowserEditorInput) { - this._proxy.$onDidChangeActiveBrowserTab(this._toDto(active)); - } else { - this._proxy.$onDidChangeActiveBrowserTab(undefined); + this._track(active); + activeId = active.id; + } + if (this._lastActiveBrowserId !== activeId) { + this._lastActiveBrowserId = activeId; + this._proxy.$onDidChangeActiveBrowserTab(activeId); } } @@ -99,12 +99,14 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers // Track property changes. Currently all the tracked properties are covered under the `onDidChangeLabel` event. disposables.add(input.onDidChangeLabel(() => { - this._proxy.$onDidChangeBrowserTabState(input.id, this._toDto(input)); + this._proxy.$onDidChangeBrowserTabState(this._toDto(input)); })); disposables.add(input.onWillDispose(() => { - this._proxy.$onDidCloseBrowserTab(input.id); this._knownBrowsers.deleteAndDispose(input.id); })); + disposables.add(toDisposable(() => { + this._proxy.$onDidCloseBrowserTab(input.id); + })); this._knownBrowsers.set(input.id, { input, dispose: () => disposables.dispose() }); this._proxy.$onDidOpenBrowserTab(this._toDto(input)); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index f40d952f332..8ac37889384 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -34,7 +34,7 @@ import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/ch import { ChatRequestParser } from '../../contrib/chat/common/requestParser/chatRequestParser.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../contrib/chat/browser/attachments/chatVariables.js'; import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatService, IChatTask, IChatTaskSerialized, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../contrib/chat/common/constants.js'; import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; @@ -253,10 +253,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA chatSessionContext = { chatSessionResource, isUntitled, - initialSessionOptions: contributedSession.initialSessionOptions?.map(o => ({ - optionId: o.optionId, - value: typeof o.value === 'string' ? o.value : o.value.id, - })), + initialSessionOptions: ChatSessionOptionsMap.toStrValueArray(contributedSession.initialSessionOptions), }; } return await this._proxy.$invokeAgent(handle, request, { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index c3c31b321d9..91214f4dd63 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -25,11 +25,12 @@ import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/c import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { awaitStatsForSession } from '../../contrib/chat/common/chat.js'; import { IChatContentInlineReference, IChatProgress, IChatService, ResponseModelState } from '../../contrib/chat/common/chatService/chatService.js'; -import { ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService, ReadonlyChatSessionOptionsMap } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { IChatAgentRequest } from '../../contrib/chat/common/participants/chatAgents.js'; +import { IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; import { IChatArtifactsService } from '../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatTodoListService } from '../../contrib/chat/common/tools/chatTodoListService.js'; import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; @@ -44,9 +45,9 @@ export class ObservableChatSession extends Disposable implements IChatSession { readonly providerHandle: number; readonly history: Array; title?: string; - private _options?: Record; - public get options(): Record | undefined { - return this._options; + private _options?: ChatSessionOptionsMap; + public get options(): ReadonlyChatSessionOptionsMap | undefined { + return this._options ? new Map(this._options) : undefined; } private readonly _progressObservable = observableValue(this, []); private readonly _isCompleteObservable = observableValue(this, false); @@ -115,7 +116,7 @@ export class ObservableChatSession extends Disposable implements IChatSession { token ); - this._options = sessionContent.options; + this._options = sessionContent.options ? ChatSessionOptionsMap.fromRecord(sessionContent.options) : undefined; this.title = sessionContent.title; this.history.length = 0; this.history.push(...sessionContent.history.map((turn: IChatSessionHistoryItemDto) => { @@ -383,7 +384,11 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes } async newChatSessionItem(request: IChatNewSessionRequest, token: CancellationToken): Promise { - const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, request, token), token); + const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, { + prompt: request.prompt, + command: request.command, + initialSessionOptions: request.initialSessionOptions ? ChatSessionOptionsMap.toStrValueArray(request.initialSessionOptions) : undefined, + }, token), token); if (!dto) { return undefined; } @@ -445,6 +450,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IChatTodoListService private readonly _chatTodoListService: IChatTodoListService, @IChatArtifactsService private readonly _chatArtifactsService: IChatArtifactsService, + @IChatDebugService private readonly _chatDebugService: IChatDebugService, @IDialogService private readonly _dialogService: IDialogService, @IEditorService private readonly _editorService: IEditorService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @@ -455,12 +461,12 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._proxy = this._extHostContext.getProxy(ExtHostContext.ExtHostChatSessions); - this._register(this._chatSessionsService.onRequestNotifyExtension(({ sessionResource, updates, waitUntil }) => { + this._register(this._chatSessionsService.onDidChangeSessionOptions(({ sessionResource, updates }) => { warnOnUntitledSessionResource(sessionResource, this._logService); const handle = this._getHandleForSessionType(sessionResource.scheme); - this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.length} update(s)`); + this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.size} update(s)`); if (handle !== undefined) { - waitUntil(this.notifyOptionsChange(handle, sessionResource, updates)); + this.notifyOptionsChange(handle, sessionResource, updates); } else { this._logService.warn(`[MainThreadChatSessions] Cannot notify option change for scheme '${sessionResource.scheme}': no provider registered. Registered schemes: [${Array.from(this._sessionTypeToHandle.keys()).join(', ')}]`); } @@ -509,7 +515,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } // We can still get stats if there is no model or if fetching from model failed - if (!item.changes || !model) { + let changes = revive(item.changes); + if (!changes || !model) { const stats = (await this._chatService.getMetadataForSession(uri))?.stats; const diffs: IAgentSession['changes'] = { files: stats?.fileCount || 0, @@ -517,13 +524,13 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat deletions: stats?.removed || 0 }; if (hasValidDiff(diffs)) { - item.changes = diffs; + changes = diffs; } } return { ...item, - changes: revive(item.changes), + changes, resource: uri, iconPath: item.iconPath, tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, @@ -546,10 +553,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat controller.addOrUpdateItem(resolvedItem); } - $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { + $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: Record): void { const sessionResource = URI.revive(sessionResourceComponents); warnOnUntitledSessionResource(sessionResource, this._logService); - this._chatSessionsService.notifySessionOptionsChange(sessionResource, updates); + this._chatSessionsService.updateSessionOptions(sessionResource, ChatSessionOptionsMap.fromRecord(updates)); } async $onDidCommitChatSessionItem(handle: number, originalComponents: UriComponents, modifiedCompoennts: UriComponents): Promise { @@ -575,6 +582,18 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // Migrate artifacts from old session to new session this._chatArtifactsService.migrateArtifacts(originalResource, modifiedResource); + // Eagerly invoke debug providers for Copilot CLI sessions so the real + // session appears in the debug panel immediately after the untitled → + // real swap. Without this, the untitled session is filtered out (it + // only has a "Load Hooks" event) and the real session has no events + // until someone navigates to it — which can't happen because it's + // not listed. + if (chatSessionType === 'copilotcli') { + // Fire-and-forget: don't block the editor swap. Errors are + // handled internally by invokeProviders via onUnexpectedError. + this._chatDebugService.invokeProviders(modifiedResource).catch(() => { /* handled internally */ }); + } + // Find the group containing the original editor const originalGroup = this.editorGroupService.groups.find(group => group.editors.some(editor => isEqual(editor.resource, originalResource))) @@ -644,18 +663,20 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } private async handleSessionModelOverrides(model: IChatModel, session: Dto): Promise> { - // Override desciription if there's an in-progress count + const outgoingSession = { ...session }; + + // Override description if there's an in-progress count const inProgress = model.getRequests().filter(r => r.response && !r.response.isComplete); if (inProgress.length) { - session.description = this._chatSessionsService.getInProgressSessionDescription(model); + outgoingSession.description = this._chatSessionsService.getInProgressSessionDescription(model); } // Override changes // TODO: @osortega we don't really use statistics anymore, we need to clarify that in the API - if (!(session.changes instanceof Array)) { + if (!(outgoingSession.changes instanceof Array)) { const modelStats = await awaitStatsForSession(model); if (modelStats) { - session.changes = { + outgoingSession.changes = { files: modelStats.fileCount, insertions: modelStats.added, deletions: modelStats.removed @@ -665,10 +686,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // Override status if the models needs input if (model.lastRequest?.response?.state === ResponseModelState.NeedsInput) { - session.status = ChatSessionStatus.NeedsInput; + outgoingSession.status = ChatSessionStatus.NeedsInput; } - return session; + return outgoingSession; } private async _provideChatSessionContent(providerHandle: number, sessionResource: URI, token: CancellationToken): Promise { @@ -696,12 +717,12 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat try { const initialSessionOptions = this._chatSessionsService.getSessionOptions(sessionResource); await session.initialize(token, { - initialSessionOptions: initialSessionOptions ? [...initialSessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined, + initialSessionOptions: initialSessionOptions ? [...initialSessionOptions].map(([optionId, value]) => ({ optionId, value: typeof value === 'string' ? value : value?.id })) : undefined, }); if (session.options) { for (const [_, handle] of this._sessionTypeToHandle) { if (handle === providerHandle) { - for (const [optionId, value] of Object.entries(session.options)) { + for (const [optionId, value] of session.options) { this._chatSessionsService.setSessionOption(sessionResource, optionId, value); } break; @@ -810,7 +831,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); } if (options?.newSessionOptions) { - this._chatSessionsService.setNewSessionOptionsForSessionType(chatSessionScheme, options.newSessionOptions); + this._chatSessionsService.setNewSessionOptionsForSessionType(chatSessionScheme, ChatSessionOptionsMap.fromRecord(options.newSessionOptions)); } }).catch(err => this._logService.error('Error fetching chat session options', err)); } @@ -850,10 +871,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat /** * Notify the extension about option changes for a session */ - async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>): Promise { + async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyMap): Promise { this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: starting proxy call for handle ${handle}, sessionResource ${sessionResource}`); try { - await this._proxy.$provideHandleOptionsChange(handle, sessionResource, updates, CancellationToken.None); + await this._proxy.$provideHandleOptionsChange(handle, sessionResource, Object.fromEntries(updates), CancellationToken.None); this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: proxy call completed for handle ${handle}, sessionResource ${sessionResource}`); } catch (error) { this._logService.error(`[MainThreadChatSessions] notifyOptionsChange: error for handle ${handle}, sessionResource ${sessionResource}:`, error); diff --git a/src/vs/workbench/api/browser/statusBarExtensionPoint.ts b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts index 4da1f68eeb1..c8d1765a7ab 100644 --- a/src/vs/workbench/api/browser/statusBarExtensionPoint.ts +++ b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts @@ -199,7 +199,7 @@ const statusBarItemSchema = { }, text: { type: 'string', - description: localize('text', 'The text to show for the entry. You can embed icons in the text by leveraging the `$()`-syntax, like \'Hello $(globe)!\'') + description: localize('text', 'The text to show for the entry. You can embed icons in the text by leveraging the `$()`-syntax, like \'Hello {0}!\'', '$(globe)') }, tooltip: { type: 'string', diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7972ca1fb01..d02ae88a0f1 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -60,7 +60,7 @@ import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common import { IChatContextItem } from '../../contrib/chat/common/contextContrib/chatContext.js'; import { IChatProgressHistoryResponseContent, IChatRequestVariableData } from '../../contrib/chat/common/model/chatModel.js'; import { ChatResponseClearToPreviousToolInvocationReason, IChatContentInlineReference, IChatExternalEditsDto, IChatFollowup, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; -import { IChatNewSessionRequest, IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments/chatVariables.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatRequestOptions, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; @@ -1371,8 +1371,8 @@ export interface MainThreadBrowsersShape extends IDisposable { export interface ExtHostBrowsersShape { $onDidOpenBrowserTab(browser: BrowserTabDto): void; $onDidCloseBrowserTab(browserId: string): void; - $onDidChangeActiveBrowserTab(browser: BrowserTabDto | undefined): void; - $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void; + $onDidChangeActiveBrowserTab(browserId: string | undefined): void; + $onDidChangeBrowserTabState(browser: BrowserTabDto): void; $onCDPSessionMessage(sessionId: string, message: CDPResponse | CDPEvent): void; $onCDPSessionClosed(sessionId: string): void; } @@ -3592,22 +3592,20 @@ export type IChatSessionHistoryItemDto = { export type IChatSessionRequestHistoryItemDto = Extract; -export interface ChatSessionOptionUpdateDto { - readonly optionId: string; - readonly value: string | IChatSessionProviderOptionItem | undefined; -} -export interface ChatSessionOptionUpdateDto2 { - readonly optionId: string; - readonly value: string | IChatSessionProviderOptionItem; -} export interface ChatSessionContentContextDto { readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; } -export interface ChatSessionDto { - id: string; +export interface IChatNewSessionRequestDto { + readonly prompt: string; + readonly command?: string; + + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; +} + +export interface IChatSessionDto { resource: UriComponents; title?: string; history: Array; @@ -3636,7 +3634,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $onDidCommitChatSessionItem(controllerHandle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; $unregisterChatSessionContentProvider(handle: number): void; - $onDidChangeChatSessionOptions(handle: number, sessionResource: UriComponents, updates: ReadonlyArray): void; + $onDidChangeChatSessionOptions(handle: number, sessionResource: UriComponents, updates: Record): void; $onDidChangeChatSessionProviderOptions(handle: number): void; $handleProgressChunk(handle: number, sessionResource: UriComponents, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise; @@ -3647,15 +3645,15 @@ export interface MainThreadChatSessionsShape extends IDisposable { export interface ExtHostChatSessionsShape { $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; - $newChatSessionItem(controllerHandle: number, request: IChatNewSessionRequest, token: CancellationToken): Promise | undefined>; + $newChatSessionItem(controllerHandle: number, request: IChatNewSessionRequestDto, token: CancellationToken): Promise | undefined>; - $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise; + $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise; $interruptChatSessionActiveResponse(providerHandle: number, sessionResource: UriComponents, requestId: string): Promise; $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise; $invokeChatSessionRequestHandler(providerHandle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise; $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; - $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; + $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: Record, token: CancellationToken): Promise; $forkChatSession(providerHandle: number, sessionResource: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise>; } diff --git a/src/vs/workbench/api/common/extHostBrowsers.ts b/src/vs/workbench/api/common/extHostBrowsers.ts index a66e3113409..bbb360c2705 100644 --- a/src/vs/workbench/api/common/extHostBrowsers.ts +++ b/src/vs/workbench/api/common/extHostBrowsers.ts @@ -237,16 +237,13 @@ export class ExtHostBrowsers extends Disposable implements ExtHostBrowsersShape } } - $onDidChangeActiveBrowserTab(dto: BrowserTabDto | undefined): void { - this._activeBrowserTabId = dto?.id; - if (dto) { - this._getOrCreateTab(dto); - } + $onDidChangeActiveBrowserTab(browserId: string | undefined): void { + this._activeBrowserTabId = browserId; this._onDidChangeActiveBrowserTab.fire(this.activeBrowserTab); } - $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void { - const tab = this._browserTabs.get(browserId); + $onDidChangeBrowserTabState(data: BrowserTabDto): void { + const tab = this._browserTabs.get(data.id); if (tab && tab.update(data)) { this._onDidChangeBrowserTabState.fire(tab.value); } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 9ecd3a3a6e4..93fb3ecebf5 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -18,11 +18,11 @@ import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; -import { IChatNewSessionRequest, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; +import { ChatSessionContentContextDto, IChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape, IChatNewSessionRequestDto } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; @@ -303,8 +303,6 @@ class ExtHostChatSession { } export class ExtHostChatSessions extends Disposable implements ExtHostChatSessionsShape { - private static _sessionHandlePool = 0; - private readonly _proxy: Proxied; private _itemControllerHandlePool = 0; @@ -485,7 +483,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio if (provider.onDidChangeChatSessionOptions) { disposables.add(provider.onDidChangeChatSessionOptions(evt => { - this._proxy.$onDidChangeChatSessionOptions(handle, evt.resource, evt.updates); + const updates: Record = Object.create(null); + for (const update of evt.updates) { + updates[update.optionId] = update.value; + } + this._proxy.$onDidChangeChatSessionOptions(handle, evt.resource, updates); })); } @@ -502,7 +504,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { + async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { throw new Error(`No provider for handle ${handle}`); @@ -520,7 +522,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const controllerData = this.getChatSessionItemController(sessionResource.scheme); const sessionDisposables = new DisposableStore(); - const sessionId = ExtHostChatSessions._sessionHandlePool++; const id = sessionResource.toString(); const chatSession = new ExtHostChatSession(session, provider.extension, { sessionResource, @@ -550,7 +551,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } const { capabilities } = provider; return { - id: sessionId + '', resource: URI.revive(sessionResource), title: session.title, hasActiveResponseCallback: !!session.activeResponseCallback, @@ -568,7 +568,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>, token: CancellationToken): Promise { + async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: Record, token: CancellationToken): Promise { const sessionResource = URI.revive(sessionResourceComponents); const provider = this._chatSessionContentProviders.get(handle); if (!provider) { @@ -582,11 +582,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } try { - const updatesToSend = updates.map(update => ({ - optionId: update.optionId, - value: update.value === undefined ? undefined : (typeof update.value === 'string' ? update.value : update.value.id) + const updatesToSend = Object.entries(updates).map(([optionId, value]) => ({ + optionId, + value: value === undefined ? undefined : (typeof value === 'string' ? value : value.id) })); - await provider.provider.provideHandleOptionsChange(sessionResource, updatesToSend, token); + provider.provider.provideHandleOptionsChange(sessionResource, updatesToSend, token); } catch (error) { this._logService.error(`Error calling provideHandleOptionsChange for handle ${handle}, sessionResource ${sessionResource}:`, error); } @@ -831,7 +831,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio await controllerData.controller.refreshHandler(token); } - async $newChatSessionItem(handle: number, request: IChatNewSessionRequest, token: CancellationToken): Promise | undefined> { + async $newChatSessionItem(handle: number, request: IChatNewSessionRequestDto, token: CancellationToken): Promise | undefined> { const controllerData = this._chatSessionItemControllers.get(handle); if (!controllerData) { this._logService.warn(`No controller found for handle ${handle}`); diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 3b01c6c8ac6..24bb32c2327 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -524,6 +524,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac disregardSearchExcludeSettings: options.useExcludeSettings !== undefined && (options.useExcludeSettings !== ExcludeSettingOptions.SearchAndFilesExclude), maxResults: options.maxResults, excludePattern: excludePatterns.length > 0 ? excludePatterns : undefined, + ignoreGlobCase: options.caseInsensitive, _reason: 'startFileSearch', shouldGlobSearch: query.type === 'include' ? undefined : true, }; @@ -597,6 +598,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac disregardSearchExcludeSettings: options.useExcludeSettings !== undefined && (options.useExcludeSettings !== ExcludeSettingOptions.SearchAndFilesExclude), fileEncoding: options.encoding, maxResults: options.maxResults, + ignoreGlobCase: options.caseInsensitive, previewOptions: options.previewOptions ? { matchLines: options.previewOptions?.numMatchLines ?? 100, charsPerLine: options.previewOptions?.charsPerLine ?? 10000, diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 545b517b921..acc7d93e4c7 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -321,7 +321,7 @@ function collectNodeSystemCertErrors(useNodeSystemCerts: boolean, logService: IL if (Array.isArray(entries)) { for (const entry of entries as { errorMessage?: string; errorCode?: number }[]) { const code = entry.errorCode ?? 'missing code'; - const error = `${category}: ${entry.errorMessage ?? 'missing message'}`; + const error = `${category}: ${sanitizeCertErrorMessage(entry.errorMessage ?? 'missing message')}`; const key = `${error} (${code})`; const existing = counts.get(key); if (existing) { @@ -341,6 +341,36 @@ function collectNodeSystemCertErrors(useNodeSystemCerts: boolean, logService: IL } } +// Sanitize known error messages to avoid false-positive redaction by the +// telemetry scrubbing regex in telemetryUtils.ts (the Generic Secret pattern +// matches "key", "sig", "signature" followed by a non-alphanumeric character). +// Source strings from Node.js RecordCertError() and OpenSSL's x509_err.c / asn1_err.c. +const certErrorReplacements: [string, string][] = [ + // Node.js RecordCertError: + ['key usage flags', 'k usage flags'], + // x509_err.c: + ['check dh key', 'check dh k'], + ['key type mismatch', 'k type mismatch'], + ['key values mismatch', 'k values mismatch'], + ['public key decode error', 'public k decode error'], + ['public key encode error', 'public k encode error'], + ['unable to get certs public key', 'unable to get certs public k'], + ['unknown key type', 'unknown k type'], + // asn1_err.c: + ['key type not supported', 'k type not supported'], + ['public key type', 'public k type'], + ['sig parse error', 's parse error'], + ['sig invalid mime type', 's invalid mime type'], + ['sig content type', 's content type'], + ['signature algorithm', 's algorithm'], +]; +function sanitizeCertErrorMessage(message: string): string { + for (const [search, replacement] of certErrorReplacements) { + message = message.replaceAll(search, replacement); + } + return message; +} + type ProxyResolveStatsClassification = { owner: 'chrmarti'; comment: 'Performance statistics for proxy resolution'; diff --git a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts index e05e040cf70..c27dab06c97 100644 --- a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts +++ b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts @@ -74,7 +74,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1', url: 'https://active.com' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.strictEqual(extHost.activeBrowserTab?.url, 'https://active.com'); }); @@ -83,23 +83,19 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.ok(extHost.activeBrowserTab); extHost.$onDidChangeActiveBrowserTab(undefined); assert.strictEqual(extHost.activeBrowserTab, undefined); }); - test('$onDidChangeActiveBrowserTab with unknown tab creates it and fires open event', () => { + test('$onDidChangeActiveBrowserTab with unknown tab returns undefined', () => { const extHost = createExtHostBrowsers(); - const opened: vscode.BrowserTab[] = []; - store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); - extHost.$onDidChangeActiveBrowserTab(createDto({ id: 'new-tab', url: 'https://new.com' })); + extHost.$onDidChangeActiveBrowserTab('non-existent'); - assert.strictEqual(extHost.activeBrowserTab?.url, 'https://new.com'); - assert.strictEqual(extHost.browserTabs.length, 1); - assert.strictEqual(opened.length, 1, 'onDidOpenBrowserTab should fire for the new tab'); + assert.strictEqual(extHost.activeBrowserTab, undefined); }); // #endregion @@ -186,7 +182,7 @@ suite('ExtHostBrowsers', () => { store.add(extHost.onDidChangeBrowserTabState(tab => changes.push(tab))); extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com' })); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com' })); assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].url, 'https://new.com'); @@ -196,7 +192,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Old Title' })); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); assert.strictEqual(extHost.browserTabs[0].url, 'https://example.com'); assert.strictEqual(extHost.browserTabs[0].title, 'New Title'); @@ -213,7 +209,7 @@ suite('ExtHostBrowsers', () => { const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); extHost.$onDidChangeActiveBrowserTab(undefined); assert.deepStrictEqual(activeChanges, ['https://example.com', undefined]); @@ -242,7 +238,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined })); assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/new.ico'); }); @@ -251,7 +247,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/icon.ico' })); assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/icon.ico'); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: undefined })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: undefined })); assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); }); @@ -379,7 +375,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com', title: 'Old' })); const tabBefore = extHost.browserTabs[0]; - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); const tabAfter = extHost.browserTabs[0]; assert.strictEqual(tabBefore, tabAfter); @@ -417,7 +413,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.ok(extHost.activeBrowserTab); extHost.$onDidCloseBrowserTab('b1'); diff --git a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts index 09190276e41..fd22da56829 100644 --- a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts @@ -883,6 +883,25 @@ suite('ExtHostWorkspace', function () { }); }); + test('caseInsensitive', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.ignoreGlobCase, true); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2([''], { caseInsensitive: true }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); + // todo: add tests with multiple filePatterns and excludes }); @@ -1096,6 +1115,24 @@ suite('ExtHostWorkspace', function () { assert(mainThreadCalled, 'mainThreadCalled'); }); + test('caseInsensitive', async () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override async $startTextSearch(query: IPatternInfo, folder: UriComponents | null, options: ITextQueryBuilderOptions, requestId: number, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.ignoreGlobCase, true); + return null; + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + await (ws.findTextInFiles2({ pattern: 'foo' }, { caseInsensitive: true }, new ExtensionIdentifier('test'))).complete; + assert(mainThreadCalled, 'mainThreadCalled'); + }); + // TODO: test multiple includes/excludess }); }); diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index ef00b137c84..9d690837308 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -7,8 +7,10 @@ import assert from 'assert'; import * as sinon from 'sinon'; import type * as vscode from 'vscode'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { asSinonMethodStub } from '../../../../base/test/common/sinonUtils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -16,13 +18,17 @@ import { ContextKeyService } from '../../../../platform/contextkey/browser/conte import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../platform/log/common/log.js'; +import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions/chatSessions.contribution.js'; -import { IChatAgentRequest } from '../../../contrib/chat/common/participants/chatAgents.js'; import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionRequestHistoryItem, IChatSessionProviderOptionGroup, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; -import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; +import { IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; +import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; +import { IChatAgentRequest, IChatAgentResult } from '../../../contrib/chat/common/participants/chatAgents.js'; +import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js'; @@ -30,18 +36,13 @@ import { IExtensionService, nullExtensionDescription } from '../../../services/e import { IViewsService } from '../../../services/views/common/viewsService.js'; import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js'; import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js'; +import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionDto, IChatSessionProviderOptions, IChatSessionRequestHistoryItemDto } from '../../common/extHost.protocol.js'; +import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; import { ExtHostChatSessions } from '../../common/extHostChatSessions.js'; import { ExtHostCommands } from '../../common/extHostCommands.js'; import { ExtHostLanguageModels } from '../../common/extHostLanguageModels.js'; -import * as extHostTypes from '../../common/extHostTypes.js'; -import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js'; -import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; import { IExtHostTelemetry } from '../../common/extHostTelemetry.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; -import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js'; -import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { Event } from '../../../../base/common/event.js'; +import * as extHostTypes from '../../common/extHostTypes.js'; import { AnyCallRPCProtocol } from '../common/testRPCProtocol.js'; suite('ObservableChatSession', function () { @@ -89,21 +90,23 @@ suite('ObservableChatSession', function () { hasActiveResponseCallback?: boolean; hasRequestHandler?: boolean; hasForkHandler?: boolean; - } = {}) { + } = {}): IChatSessionDto { + const id = options.id || 'test-id'; return { - id: options.id || 'test-id', + resource: LocalChatSessionUri.forSession(id), title: options.title, history: options.history || [], hasActiveResponseCallback: options.hasActiveResponseCallback ?? false, hasRequestHandler: options.hasRequestHandler ?? false, - hasForkHandler: options.hasForkHandler ?? false + hasForkHandler: options.hasForkHandler ?? false, + supportsInterruption: false, }; } async function createInitializedSession(sessionContent: any, sessionId = 'test-id'): Promise { const resource = LocalChatSessionUri.forSession(sessionId); const session = new ObservableChatSession(resource, 1, proxy, logService, dialogService); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions: [] }); return session; } @@ -140,7 +143,7 @@ suite('ObservableChatSession', function () { // Initialize the session const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions: [] }); // Now progress should be visible @@ -194,10 +197,10 @@ suite('ObservableChatSession', function () { deletions: 2, }], }; - (proxy.$forkChatSession as sinon.SinonStub).resolves(forkedItem); + asSinonMethodStub(proxy.$forkChatSession).resolves(forkedItem); const request: IChatSessionRequestHistoryItem = { type: 'request', id: 'request-1', prompt: 'Previous question', participant: 'participant' }; - const expectedRequestDto = { + const expectedRequestDto: IChatSessionRequestHistoryItemDto = { type: 'request', id: 'request-1', prompt: 'Previous question', @@ -208,7 +211,7 @@ suite('ObservableChatSession', function () { }; const result = await session.forkSession?.(request, CancellationToken.None); - assert.ok((proxy.$forkChatSession as sinon.SinonStub).calledOnceWithExactly(1, session.sessionResource, expectedRequestDto, CancellationToken.None)); + assert.ok(asSinonMethodStub(proxy.$forkChatSession).calledOnceWithExactly(1, session.sessionResource, expectedRequestDto, CancellationToken.None)); assert.ok(result); assert.ok(result.resource instanceof URI); assert.ok(Array.isArray(result.changes)); @@ -239,7 +242,7 @@ suite('ObservableChatSession', function () { const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService)); const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const promise1 = session.initialize(CancellationToken.None, { initialSessionOptions: [] }); const promise2 = session.initialize(CancellationToken.None, { initialSessionOptions: [] }); @@ -248,7 +251,7 @@ suite('ObservableChatSession', function () { await promise1; // Should only call proxy once even though initialize was called twice - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce); + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnce); }); test('initialization forwards initial session options context', async function () { @@ -258,11 +261,11 @@ suite('ObservableChatSession', function () { const initialSessionOptions = [{ optionId: 'model', value: 'gpt-4.1' }]; const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions }); - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnceWith( + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnceWith( 1, resource, { initialSessionOptions }, @@ -332,7 +335,7 @@ suite('ObservableChatSession', function () { await session.requestHandler!(request, progressCallback, [], CancellationToken.None); - assert.ok((proxy.$invokeChatSessionRequestHandler as sinon.SinonStubbedMember).calledOnceWith(1, session.sessionResource, request, [], CancellationToken.None)); + assert.ok(asSinonMethodStub(proxy.$invokeChatSessionRequestHandler).calledOnceWith(1, session.sessionResource, request, [], CancellationToken.None)); }); test('request handler forwards progress updates to external callback', async function () { @@ -351,12 +354,12 @@ suite('ObservableChatSession', function () { }; const progressCallback = sinon.stub(); - let resolveRequest: () => void; - const requestPromise = new Promise(resolve => { + let resolveRequest: (value: IChatAgentResult) => void; + const requestPromise = new Promise(resolve => { resolveRequest = resolve; }); - (proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).returns(requestPromise); + asSinonMethodStub(proxy.$invokeChatSessionRequestHandler).returns(requestPromise); const requestHandlerPromise = session.requestHandler!(request, progressCallback, [], CancellationToken.None); @@ -374,7 +377,7 @@ suite('ObservableChatSession', function () { assert.deepStrictEqual(progressCallback.secondCall.args[0], [progress2]); // Complete the request - resolveRequest!(); + resolveRequest!({}); await requestHandlerPromise; assert.strictEqual(session.isCompleteObs.get(), true); @@ -393,7 +396,7 @@ suite('ObservableChatSession', function () { session.dispose(); assert.ok(disposeEventFired); - assert.ok((proxy.$disposeChatSessionContent as sinon.SinonStubbedMember).calledOnceWith(1, resource)); + assert.ok(asSinonMethodStub(proxy.$disposeChatSessionContent).calledOnceWith(1, resource)); disposable.dispose(); }); @@ -512,16 +515,17 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - const resource = URI.parse(`${sessionScheme}:/test-session`); - - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session1 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.ok(session1); @@ -529,7 +533,7 @@ suite('MainThreadChatSessions', function () { const session2 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.strictEqual(session1, session2); - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce); + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnce); mainThread.$unregisterChatSessionContentProvider(1); }); @@ -537,17 +541,18 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, title: 'My Session Title', history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - const resource = URI.parse(`${sessionScheme}:/test-session`); - - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.strictEqual(session.title, 'My Session Title'); @@ -560,16 +565,18 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } }; @@ -585,16 +592,18 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } }; @@ -610,21 +619,22 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'multi-turn-session', + const resource = URI.parse(`${sessionScheme}:/multi-turn-session`); + const sessionContent: IChatSessionDto = { + resource, history: [ - { type: 'request', prompt: 'First question' }, - { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] }, - { type: 'request', prompt: 'Second question' }, - { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] } + { type: 'request', prompt: 'First question', participant: 'test-participant' }, + { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }], participant: 'test-participant' }, + { type: 'request', prompt: 'Second question', participant: 'test-participant' }, + { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }], participant: 'test-participant' } ], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); - - const resource = URI.parse(`${sessionScheme}:/multi-turn-session`); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; // Verify the session loaded correctly @@ -660,7 +670,7 @@ suite('MainThreadChatSessions', function () { items: [{ id: 'modelB', name: 'Model B' }] }]; - const provideOptionsStub = proxy.$provideChatSessionProviderOptions as sinon.SinonStub; + const provideOptionsStub = asSinonMethodStub(proxy.$provideChatSessionProviderOptions); provideOptionsStub.onFirstCall().resolves({ optionGroups: optionGroups1 } as IChatSessionProviderOptions); provideOptionsStub.onSecondCall().resolves({ optionGroups: optionGroups2 } as IChatSessionProviderOptions); @@ -688,17 +698,18 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, - // No options provided + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // getSessionOption should return undefined for unset options @@ -712,20 +723,22 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4', 'region': { id: 'us-east', name: 'US East' } } }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // getSessionOption should return the configured values @@ -744,35 +757,35 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(handle, sessionScheme); - const sessionContent = { - id: 'test-session', + const sessionContent: IChatSessionDto = { + resource: URI.parse(`${sessionScheme}:/test-session`), history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4' } }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // Clear the stub call history - (proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory(); + asSinonMethodStub(proxy.$provideHandleOptionsChange).resetHistory(); // Simulate an option change - await chatSessionsService.notifySessionOptionsChange(resource, [ - { optionId: 'models', value: 'gpt-4-turbo' } - ]); + chatSessionsService.setSessionOption(resource, 'models', 'gpt-4-turbo'); // Verify the extension was notified - assert.ok((proxy.$provideHandleOptionsChange as sinon.SinonStub).calledOnce); - const call = (proxy.$provideHandleOptionsChange as sinon.SinonStub).firstCall; + assert.ok(asSinonMethodStub(proxy.$provideHandleOptionsChange).calledOnce); + const call = asSinonMethodStub(proxy.$provideHandleOptionsChange).firstCall; assert.strictEqual(call.args[0], handle); assert.deepStrictEqual(call.args[1], resource); - assert.deepStrictEqual(call.args[2], [{ optionId: 'models', value: 'gpt-4-turbo' }]); + assert.deepStrictEqual(call.args[2], { models: 'gpt-4-turbo' }); mainThread.$unregisterChatSessionContentProvider(handle); }); @@ -785,33 +798,34 @@ suite('MainThreadChatSessions', function () { const resource = URI.parse(`${sessionScheme}:/test-session`); // Clear any previous calls - (proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory(); + asSinonMethodStub(proxy.$provideHandleOptionsChange).resetHistory(); // Attempt to notify option change for an unregistered scheme // This should not throw, but also should not call the proxy - await chatSessionsService.notifySessionOptionsChange(resource, [ - { optionId: 'models', value: 'gpt-4-turbo' } - ]); + chatSessionsService.updateSessionOptions(resource, new Map([ + ['models', 'gpt-4-turbo'] + ])); // Verify the extension was NOT notified (no provider registered) - assert.strictEqual((proxy.$provideHandleOptionsChange as sinon.SinonStub).callCount, 0); + assert.strictEqual(asSinonMethodStub(proxy.$provideHandleOptionsChange).callCount, 0); }); test('setSessionOption updates option and getSessionOption reflects change', async function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, - // Start with no options + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // Initially no options set @@ -830,30 +844,34 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); + const resourceWithOptions = URI.parse(`${sessionScheme}:/session-with-options`); + const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`); + // Session with options - const sessionContentWithOptions = { - id: 'session-with-options', + const sessionContentWithOptions: IChatSessionDto = { + resource: resourceWithOptions, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4' } }; // Session without options - const sessionContentWithoutOptions = { - id: 'session-without-options', + const sessionContentWithoutOptions: IChatSessionDto = { + resource: resourceWithoutOptions, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub) + asSinonMethodStub(proxy.$provideChatSessionContent) .onFirstCall().resolves(sessionContentWithOptions) .onSecondCall().resolves(sessionContentWithoutOptions); - const resourceWithOptions = URI.parse(`${sessionScheme}:/session-with-options`); - const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`); - await chatSessionsService.getOrCreateChatSession(resourceWithOptions, CancellationToken.None); await chatSessionsService.getOrCreateChatSession(resourceWithoutOptions, CancellationToken.None); diff --git a/src/vs/workbench/browser/actions/quickAccessActions.ts b/src/vs/workbench/browser/actions/quickAccessActions.ts index 35cdd20ccc3..48aca00b6f3 100644 --- a/src/vs/workbench/browser/actions/quickAccessActions.ts +++ b/src/vs/workbench/browser/actions/quickAccessActions.ts @@ -9,13 +9,16 @@ import { KeyMod, KeyCode } from '../../../base/common/keyCodes.js'; import { KeybindingsRegistry, KeybindingWeight, IKeybindingRule } from '../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, ItemActivation, QuickInputHideReason } from '../../../platform/quickinput/common/quickInput.js'; import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { inQuickPickContext, defaultQuickAccessContext, getQuickNavigateHandler } from '../quickaccess.js'; import { ILocalizedString } from '../../../platform/action/common/action.js'; import { AnythingQuickAccessProviderRunOptions } from '../../../platform/quickinput/common/quickAccess.js'; import { Codicon } from '../../../base/common/codicons.js'; +const UNIFIED_AGENTS_BAR_SETTING = 'chat.unifiedAgentsBar.enabled'; + //#region Quick access management commands and keys const globalQuickAccessKeybinding = { @@ -161,16 +164,32 @@ registerAction2(class QuickAccessAction extends Action2 { }); } - run(accessor: ServicesAccessor): void { - const quickInputService = accessor.get(IQuickInputService); - const providerOptions: AnythingQuickAccessProviderRunOptions = { - includeHelp: true, - from: 'commandCenter', + async run(accessor: ServicesAccessor): Promise { + const openClassicQuickAccess = (): void => { + const quickInputService = accessor.get(IQuickInputService); + const providerOptions: AnythingQuickAccessProviderRunOptions = { + includeHelp: true, + from: 'commandCenter', + }; + quickInputService.quickAccess.show(undefined, { + preserveValue: true, + providerOptions + }); }; - quickInputService.quickAccess.show(undefined, { - preserveValue: true, - providerOptions - }); + + const configurationService = accessor.get(IConfigurationService); + const commandService = accessor.get(ICommandService); + const useUnifiedQuickAccess = configurationService.getValue(UNIFIED_AGENTS_BAR_SETTING) === true; + if (useUnifiedQuickAccess) { + try { + await commandService.executeCommand('workbench.action.unifiedQuickAccess'); + } catch { + openClassicQuickAccess(); + } + return; + } + + openClassicQuickAccess(); } }); diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index 2f28eca7419..cdf065ca432 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -22,6 +22,11 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { WindowTitle } from './windowTitle.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; + +const AI_DISABLED_SETTING = 'chat.disableAIFeatures'; +const AI_CUSTOMIZATION_MENU_ENABLED_SETTING = 'chat.customizationsMenu.enabled'; +const AGENT_STATUS_ENABLED_SETTING = 'chat.agentsControl.enabled'; export class CommandCenterControl { @@ -86,6 +91,7 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { @IKeybindingService private _keybindingService: IKeybindingService, @IInstantiationService private _instaService: IInstantiationService, @IEditorGroupsService private _editorGroupService: IEditorGroupsService, + @IConfigurationService private _configurationService: IConfigurationService, ) { super(undefined, _submenu.actions.find(action => action.id === 'workbench.action.quickOpenWithModes') ?? _submenu.actions[0], options); this._hoverDelegate = options.hoverDelegate ?? getDefaultHoverDelegate('mouse'); @@ -143,9 +149,21 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { container.classList.toggle('command-center-quick-pick'); container.role = 'button'; container.setAttribute('aria-description', this.getTooltip()); + + // When agent control mode is 'compact', hide search icon and left-align the label + // Backward compat: the old boolean setting (true) and the new default (undefined) both map to compact + const aiFeaturesDisabled = that._configurationService.getValue(AI_DISABLED_SETTING) === true; + const aiCustomizationsDisabled = that._configurationService.getValue('disableAICustomizations') === true + || that._configurationService.getValue('workbench.disableAICustomizations') === true + || that._configurationService.getValue(AI_CUSTOMIZATION_MENU_ENABLED_SETTING) === false; + const forcedHidden = aiFeaturesDisabled && aiCustomizationsDisabled; + const agentControlValue = that._configurationService.getValue(AGENT_STATUS_ENABLED_SETTING); + const isCompactMode = !forcedHidden && (agentControlValue === true || agentControlValue === undefined || agentControlValue === 'compact'); + container.classList.toggle('compact-mode', isCompactMode); + const action = this.action; - // icon (search) + // icon (search) - hidden in compact mode const searchIcon = document.createElement('span'); searchIcon.ariaHidden = 'true'; searchIcon.className = action.class ?? ''; @@ -156,7 +174,11 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { const labelElement = document.createElement('span'); labelElement.classList.add('search-label'); labelElement.textContent = label; - reset(container, searchIcon, labelElement); + if (isCompactMode) { + reset(container, labelElement); + } else { + reset(container, searchIcon, labelElement); + } const hover = this._store.add(that._hoverService.setupManagedHover(that._hoverDelegate, container, this.getTooltip())); diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index 9f8ee2dca9f..7a255b58d0f 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -203,6 +203,25 @@ text-overflow: ellipsis; } +/* Compact mode: left-aligned label, no icon, full width */ +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center .action-item.command-center-quick-pick.compact-mode { + margin: auto auto auto 0; + padding-left: 8px; + flex: 1; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar > .monaco-action-bar > .actions-container { + margin: 0; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar { + flex: 1; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar > .monaco-action-bar { + width: 100%; +} + .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center.multiple { justify-content: flex-start; padding: 0 12px; diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index b439e870035..6bd3eab98cb 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -364,6 +364,13 @@ export interface IViewContainerModel { readonly keybindingId: string | undefined; readonly onDidChangeContainerInfo: Event<{ title?: boolean; icon?: boolean; keybindingId?: boolean; badgeEnablement?: boolean }>; + /** + * Re-reads the container info (title, icon, keybinding) and fires + * `onDidChangeContainerInfo` if anything changed. Call this when + * the container's dynamic title has been updated externally. + */ + refreshContainerInfo(): void; + readonly allViewDescriptors: ReadonlyArray; readonly onDidChangeAllViewDescriptors: Event<{ added: ReadonlyArray; removed: ReadonlyArray }>; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 26ce86842e3..c4f7bb4bb27 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -31,13 +31,11 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { BrowserOverlayManager, BrowserOverlayType, IBrowserOverlayInfo } from './overlayManager.js'; import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; -import { BrowserFindWidget, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserFindWidget.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; @@ -49,13 +47,8 @@ import { ILayoutService } from '../../../../platform/layout/browser/layoutServic export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); -export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error")); -export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); - -// Re-export find widget context keys for use in actions -export { CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE }; /** * Get the original implementation of HTMLElement focus (without window auto-focusing) @@ -102,6 +95,17 @@ export abstract class BrowserEditorContribution extends Disposable { * Contributions can override this getter to provide widgets. */ get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { return []; } + + /** + * Optional toolbar-like elements to insert into the editor root between the navbar and the + * browser container. Contributions can override this getter to provide elements. + */ + get toolbarElements(): readonly HTMLElement[] { return []; } + + /** + * Called when the editor is laid out with a new dimension. + */ + layout(_width: number): void { } } /** @@ -356,14 +360,10 @@ export class BrowserEditor extends EditorPane { private _overlayPauseDetail!: HTMLElement; private _errorContainer!: HTMLElement; private _welcomeContainer!: HTMLElement; - private _findWidgetContainer!: HTMLElement; - private _findWidget!: Lazy; private _canGoBackContext!: IContextKey; private _canGoForwardContext!: IContextKey; - private _storageScopeContext!: IContextKey; private _hasUrlContext!: IContextKey; private _hasErrorContext!: IContextKey; - private _devToolsOpenContext!: IContextKey; private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; @@ -395,10 +395,8 @@ export class BrowserEditor extends EditorPane { // Bind navigation capability context keys this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService); this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); - this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); - this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); @@ -418,11 +416,11 @@ export class BrowserEditor extends EditorPane { const root = $('.browser-root'); parent.appendChild(root); - // Create toolbar with navigation buttons and URL input - const toolbar = $('.browser-toolbar'); + // Create navbar with navigation buttons and URL input + const navbar = $('.browser-navbar'); // Create navigation bar widget with scoped context - this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService)); + this._navigationBar = this._register(new BrowserNavigationBar(this, navbar, this.instantiationService, contextKeyService)); // Inject URL bar widgets from contributions const allWidgets: IBrowserEditorWidgetContribution[] = []; @@ -431,27 +429,14 @@ export class BrowserEditor extends EditorPane { } this._navigationBar.addUrlBarWidgets(allWidgets); - root.appendChild(toolbar); + root.appendChild(navbar); - // Create find widget container (between toolbar and browser container) - this._findWidgetContainer = $('.browser-find-widget-wrapper'); - root.appendChild(this._findWidgetContainer); - - // Create find widget (lazy initialization) - this._findWidget = new Lazy(() => { - const findWidget = this.instantiationService.createInstance( - BrowserFindWidget, - this._findWidgetContainer - ); - if (this._model) { - findWidget.setModel(this._model); + // Collect toolbar elements from contributions (e.g. find widget container) + for (const contribution of this._contributionInstances.values()) { + for (const element of contribution.toolbarElements) { + root.appendChild(element); } - findWidget.onDidChangeHeight(() => { - this.layoutBrowserContainer(); - }); - return findWidget; - }); - this._register(toDisposable(() => this._findWidget.rawValue?.dispose())); + } // Create browser container wrapper (flex item that fills remaining space) this._browserContainerWrapper = $('.browser-container-wrapper'); @@ -494,10 +479,16 @@ export class BrowserEditor extends EditorPane { // When the browser container gets focus, make sure the browser view also gets focused. // But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view). if (event.relatedTarget && this._model && this.shouldShowView) { - void this._model.focus(); + this.requestFocus(); } })); + this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => { + // If the container becomes blurred, cancel any scheduled focus call. + // This can happen when e.g. a menu closes and focus shifts back to the browser, then immediately focuses another element. + this.cancelFocus(); + })); + // Register external focus checker so that cross-window focus logic knows when // this browser view has focus (since it's outside the normal DOM tree). // Include window info so that UI like dialogs appear in the correct window. @@ -507,6 +498,35 @@ export class BrowserEditor extends EditorPane { }))); } + override focus(): void { + if (this._model?.url && !this._model.error) { + this.requestFocus(); + } else { + this.focusUrlInput(); + } + } + + private _focusTimeout: ReturnType | undefined; + private requestFocus(): void { + this.ensureBrowserFocus(); + if (this._focusTimeout) { + return; + } + this._focusTimeout = setTimeout(() => { + this._focusTimeout = undefined; + if (this._model) { + void this._model.focus(); + } + }, 0); + } + + private cancelFocus(): void { + if (this._focusTimeout) { + clearTimeout(this._focusTimeout); + this._focusTimeout = undefined; + } + } + override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); if (token.isCancellationRequested) { @@ -525,12 +545,6 @@ export class BrowserEditor extends EditorPane { this._model = model; this._onDidChangeModel.fire(model); - this._storageScopeContext.set(this._model.storageScope); - this._devToolsOpenContext.set(this._model.isDevToolsOpen); - - // Update find widget with new model - this._findWidget.rawValue?.setModel(this._model); - // Initialize UI state and context keys from model this.updateNavigationState({ url: this._model.url, @@ -541,18 +555,6 @@ export class BrowserEditor extends EditorPane { }); this.setBackgroundImage(this._model.screenshot); - if (!options?.preserveFocus) { - setTimeout(() => { - if (this._model === model) { - if (this._model.url) { - this._browserContainer.focus(); - } else { - this.focusUrlInput(); - } - } - }, 0); - } - // Start / stop screenshots when the model visibility changes this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); @@ -582,10 +584,6 @@ export class BrowserEditor extends EditorPane { } })); - this._inputDisposables.add(this._model.onDidChangeDevToolsState(e => { - this._devToolsOpenContext.set(e.isDevToolsOpen); - })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, url, location, position }) => { logBrowserOpen(this.telemetryService, (() => { switch (location) { @@ -672,7 +670,7 @@ export class BrowserEditor extends EditorPane { this._browserContainer.ownerDocument.activeElement === this._browserContainer ) { // If the editor is focused, ensure the browser view also gets focus - void this._model.focus(); + this.requestFocus(); } } else { this.doScreenshot(); @@ -906,40 +904,6 @@ export class BrowserEditor extends EditorPane { return this._model?.clearStorage(); } - /** - * Show the find widget, optionally pre-populated with selected text from the browser view - */ - async showFind(): Promise { - // Get selected text from the browser view to pre-populate the search box. - const selectedText = (await this._model?.getSelectedText())?.trim(); - - // Only use the selected text if it doesn't contain newlines (single line selection) - const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; - this._findWidget.value.reveal(textToReveal); - this._findWidget.value.layout(this._findWidgetContainer.clientWidth); - } - - /** - * Hide the find widget - */ - hideFind(): void { - this._findWidget.rawValue?.hide(); - } - - /** - * Find the next match - */ - findNext(): void { - this._findWidget.rawValue?.find(false); - } - - /** - * Find the previous match - */ - findPrevious(): void { - this._findWidget.rawValue?.find(true); - } - /** * Update navigation state and context keys */ @@ -1051,9 +1015,10 @@ export class BrowserEditor extends EditorPane { } override layout(dimension?: Dimension, _position?: IDomPosition): void { - // Layout find widget if it exists - if (dimension && this._findWidget.rawValue) { - this._findWidget.rawValue.layout(dimension.width); + if (dimension) { + for (const contribution of this._contributionInstances.values()) { + contribution.layout(dimension.width); + } } const whenContainerStylesLoaded = this.layoutService.whenContainerStylesLoaded(this.window); @@ -1071,7 +1036,7 @@ export class BrowserEditor extends EditorPane { * Recompute the layout of the browser container and update the model with the new bounds. * This should generally only be called via layout() to ensure that the container is ready and all necessary styles are loaded. */ - private layoutBrowserContainer(): void { + layoutBrowserContainer(): void { if (this._model) { this.checkOverlays(); @@ -1093,12 +1058,9 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); - // Cancel any scheduled screenshots + // Cancel any scheduled timers this.cancelScheduledScreenshot(); - - // Clear find widget model - this._findWidget.rawValue?.setModel(undefined); - this._findWidget.rawValue?.hide(); + this.cancelFocus(); void this._model?.setVisible(false); this._model = undefined; @@ -1108,8 +1070,6 @@ export class BrowserEditor extends EditorPane { this._canGoForwardContext.reset(); this._hasUrlContext.reset(); this._hasErrorContext.reset(); - this._storageScopeContext.reset(); - this._devToolsOpenContext.reset(); this._navigationBar.clear(); this.setBackgroundImage(undefined); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts deleted file mode 100644 index dc4aba47e85..00000000000 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts +++ /dev/null @@ -1,189 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { SimpleFindWidget } from '../../codeEditor/browser/find/simpleFindWidget.js'; -import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; -import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; -import { IBrowserViewModel } from '../common/browserView.js'; -import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; -import { localize } from '../../../../nls.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { getWindow } from '../../../../base/browser/dom.js'; - -export const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); -export const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserFindWidgetFocused', false, localize('browser.findWidgetFocused', "Whether the browser find widget is focused")); - -/** - * Find widget for the integrated browser view. - * Uses the SimpleFindWidget base class and communicates with the browser view model - * to perform find operations in the rendered web page. - */ -export class BrowserFindWidget extends SimpleFindWidget { - private _model: IBrowserViewModel | undefined; - private readonly _modelDisposables = this._register(new DisposableStore()); - private readonly _findWidgetVisible: IContextKey; - private readonly _findWidgetFocused: IContextKey; - private _lastFindResult: { resultIndex: number; resultCount: number } | undefined; - private _hasFoundMatch = false; - - private readonly _onDidChangeHeight = this._register(new Emitter()); - readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; - - constructor( - private readonly container: HTMLElement, - @IContextViewService contextViewService: IContextViewService, - @IContextKeyService contextKeyService: IContextKeyService, - @IHoverService hoverService: IHoverService, - @IKeybindingService keybindingService: IKeybindingService, - @IConfigurationService configurationService: IConfigurationService, - @IAccessibilityService accessibilityService: IAccessibilityService - ) { - super({ - showCommonFindToggles: true, - checkImeCompletionState: true, - showResultCount: true, - enableSash: true, - initialWidth: 350, - previousMatchActionId: BrowserViewCommandId.FindPrevious, - nextMatchActionId: BrowserViewCommandId.FindNext, - closeWidgetActionId: BrowserViewCommandId.HideFind - }, contextViewService, contextKeyService, hoverService, keybindingService, configurationService, accessibilityService); - - this._findWidgetVisible = CONTEXT_BROWSER_FIND_WIDGET_VISIBLE.bindTo(contextKeyService); - this._findWidgetFocused = CONTEXT_BROWSER_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); - - const domNode = this.getDomNode(); - container.appendChild(domNode); - - let lastHeight = domNode.offsetHeight; - const resizeObserver = new (getWindow(container).ResizeObserver)(() => { - const newHeight = domNode.offsetHeight; - if (newHeight !== lastHeight) { - lastHeight = newHeight; - this._onDidChangeHeight.fire(); - } - }); - resizeObserver.observe(domNode); - this._register(toDisposable(() => resizeObserver.disconnect())); - } - - /** - * Set the browser view model to use for find operations. - * This should be called whenever the editor input changes. - */ - setModel(model: IBrowserViewModel | undefined): void { - this._modelDisposables.clear(); - this._model = model; - this._lastFindResult = undefined; - this._hasFoundMatch = false; - - if (model) { - this._modelDisposables.add(model.onDidFindInPage(result => { - this._lastFindResult = { - resultIndex: result.activeMatchOrdinal - 1, // Convert to 0-based index - resultCount: result.matches - }; - this._hasFoundMatch = result.matches > 0; - this.updateButtons(this._hasFoundMatch); - this.updateResultCount(); - })); - - this._modelDisposables.add(model.onWillDispose(() => { - this.setModel(undefined); - })); - } - } - - override reveal(initialInput?: string): void { - const wasVisible = this.isVisible(); - super.reveal(initialInput); - this._findWidgetVisible.set(true); - this.container.classList.toggle('find-visible', true); - - // Focus the find input - this.focusFindBox(); - - // If there's existing input and the widget wasn't already visible, trigger a search - if (this.inputValue && !wasVisible) { - this._onInputChanged(); - } - } - - override hide(): void { - super.hide(false); - this._findWidgetVisible.reset(); - this.container.classList.toggle('find-visible', false); - - // Stop find and clear highlights in the browser view - this._model?.stopFindInPage(true); - this._model?.focus(); - this._lastFindResult = undefined; - this._hasFoundMatch = false; - } - - find(previous: boolean): void { - const value = this.inputValue; - if (value && this._model) { - this._model.findInPage(value, { - forward: !previous, - recompute: false, - matchCase: this._getCaseSensitiveValue() - }); - } - } - - findFirst(): void { - const value = this.inputValue; - if (value && this._model) { - this._model.findInPage(value, { - forward: true, - recompute: true, - matchCase: this._getCaseSensitiveValue() - }); - } - } - - clear(): void { - if (this._model) { - this._model.stopFindInPage(false); - this._lastFindResult = undefined; - this._hasFoundMatch = false; - } - } - - protected _onInputChanged(): boolean { - if (this.inputValue) { - this.findFirst(); - } else if (this._model) { - this.clear(); - } - return false; - } - - protected async _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined> { - return this._lastFindResult; - } - - protected _onFocusTrackerFocus(): void { - this._findWidgetFocused.set(true); - } - - protected _onFocusTrackerBlur(): void { - this._findWidgetFocused.reset(); - } - - protected _onFindInputFocusTrackerFocus(): void { - // No-op - } - - protected _onFindInputFocusTrackerBlur(): void { - // No-op - } -} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 6bf1b7da480..ac62107adcd 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -11,32 +11,23 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { BrowserEditor } from './browserEditor.js'; import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { Schemas } from '../../../../base/common/network.js'; import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; import { BrowserViewCDPService } from './browserViewCDPService.js'; -import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; -import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; // Register actions and browser features import './browserViewActions.js'; +import './features/browserDataStorageFeatures.js'; +import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; import './features/browserEditorZoomFeature.js'; +import './features/browserEditorFindFeature.js'; +import './features/browserTabManagementFeatures.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( @@ -103,93 +94,5 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { registerWorkbenchContribution2(BrowserEditorResolverContribution.ID, BrowserEditorResolverContribution, WorkbenchPhase.BlockStartup); -/** - * Opens localhost URLs in the Integrated Browser when the setting is enabled. - */ -class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IExternalOpener { - static readonly ID = 'workbench.contrib.localhostLinkOpener'; - - constructor( - @IOpenerService openerService: IOpenerService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IEditorService private readonly editorService: IEditorService, - @ITelemetryService private readonly telemetryService: ITelemetryService - ) { - super(); - - this._register(openerService.registerExternalOpener(this)); - } - - async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { - if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { - return false; - } - - try { - const parsed = new URL(href); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return false; - } - if (!isLocalhostAuthority(parsed.host)) { - return false; - } - } catch { - return false; - } - - logBrowserOpen(this.telemetryService, 'localhostLinkOpener'); - - const browserUri = BrowserViewUri.forId(generateUuid()); - await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href } } }); - return true; - } -} - -registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); - registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); registerSingleton(IBrowserViewCDPService, BrowserViewCDPService, InstantiationType.Delayed); - -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - ...workbenchConfigurationNodeBase, - properties: { - 'workbench.browser.showInTitleBar': { - type: 'boolean', - default: false, - experiment: { mode: 'startup' }, - description: localize( - { comment: ['This is the description for a setting.'], key: 'browser.showInTitleBar' }, - 'Controls whether the Integrated Browser button is shown in the title bar.' - ) - }, - 'workbench.browser.openLocalhostLinks': { - type: 'boolean', - default: false, - markdownDescription: localize( - { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, - 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' - ) - }, - 'workbench.browser.dataStorage': { - type: 'string', - enum: [ - BrowserViewStorageScope.Global, - BrowserViewStorageScope.Workspace, - BrowserViewStorageScope.Ephemeral - ], - markdownEnumDescriptions: [ - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') - ], - restricted: true, - default: BrowserViewStorageScope.Global, - markdownDescription: localize( - { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, - 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' - ), - scope: ConfigurationScope.WINDOW, - order: 100 - } - } -}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 46011e85290..eb04e8d3a73 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -3,25 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from '../../../../nls.js'; +import { localize2 } from '../../../../nls.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { Action2, registerAction2, MenuId } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; -import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; -import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { IBrowserViewWorkbenchService } from '../common/browserView.js'; -import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_URL } from './browserEditor.js'; +import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { ToggleTitleBarConfigAction } from '../../../browser/parts/titlebar/titlebarActions.js'; // Context key expression to check if browser editor is active export const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); @@ -34,80 +28,6 @@ export enum BrowserActionGroup { Settings = '4_settings' } -interface IOpenBrowserOptions { - url?: string; - openToSide?: boolean; -} - -class OpenIntegratedBrowserAction extends Action2 { - constructor() { - super({ - id: BrowserViewCommandId.Open, - title: localize2('browser.openAction', "Open Integrated Browser"), - category: BrowserActionCategory, - icon: Codicon.globe, - f1: true, - menu: { - id: MenuId.TitleBar, - group: 'navigation', - order: 10, - when: ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', true) - } - }); - } - - async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { - const editorService = accessor.get(IEditorService); - const telemetryService = accessor.get(ITelemetryService); - - // Parse arguments - const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); - const resource = BrowserViewUri.forId(generateUuid()); - const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP; - - logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); - - const editorPane = await editorService.openEditor({ resource, options: { viewState: { url: options.url } } }, group); - - // Lock the group when opening to the side - if (options.openToSide && editorPane?.group) { - editorPane.group.lock(true); - } - } -} - -class NewTabAction extends Action2 { - constructor() { - super({ - id: BrowserViewCommandId.NewTab, - title: localize2('browser.newTabAction', "New Tab"), - category: BrowserActionCategory, - f1: true, - precondition: BROWSER_EDITOR_ACTIVE, - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Tabs, - order: 1, - }, - // When already in a browser, Ctrl/Cmd + T opens a new tab - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions - primary: KeyMod.CtrlCmd | KeyCode.KeyT, - } - }); - } - - async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - const editorService = accessor.get(IEditorService); - const telemetryService = accessor.get(ITelemetryService); - const resource = BrowserViewUri.forId(generateUuid()); - - logBrowserOpen(telemetryService, 'newTabCommand'); - - await editorService.openEditor({ resource }); - } -} - class GoBackAction extends Action2 { static readonly ID = BrowserViewCommandId.GoBack; @@ -262,37 +182,6 @@ class FocusUrlInputAction extends Action2 { } } -class ToggleDevToolsAction extends Action2 { - static readonly ID = BrowserViewCommandId.ToggleDevTools; - - constructor() { - super({ - id: ToggleDevToolsAction.ID, - title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), - category: BrowserActionCategory, - icon: Codicon.terminal, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), - menu: { - id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 3, - }, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.F12 - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.toggleDevTools(); - } - } -} - class OpenInExternalBrowserAction extends Action2 { static readonly ID = BrowserViewCommandId.OpenExternal; @@ -329,83 +218,6 @@ class OpenInExternalBrowserAction extends Action2 { } } -class ClearGlobalBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearGlobalStorage; - - constructor() { - super({ - id: ClearGlobalBrowserStorageAction.ID, - title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), - category: BrowserActionCategory, - icon: Codicon.clearAll, - f1: true, - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Settings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - await browserViewWorkbenchService.clearGlobalStorage(); - } -} - -class ClearWorkspaceBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearWorkspaceStorage; - - constructor() { - super({ - id: ClearWorkspaceBrowserStorageAction.ID, - title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), - category: BrowserActionCategory, - icon: Codicon.clearAll, - f1: true, - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Settings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - await browserViewWorkbenchService.clearWorkspaceStorage(); - } -} - -class ClearEphemeralBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearEphemeralStorage; - - constructor() { - super({ - id: ClearEphemeralBrowserStorageAction.ID, - title: localize2('browser.clearEphemeralStorageAction', 'Clear Storage (Ephemeral)'), - category: BrowserActionCategory, - icon: Codicon.clearAll, - f1: true, - precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Settings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.clearStorage(); - } - } -} - class OpenBrowserSettingsAction extends Action2 { static readonly ID = BrowserViewCommandId.OpenSettings; @@ -430,145 +242,12 @@ class OpenBrowserSettingsAction extends Action2 { } } -// Find actions - -class ShowBrowserFindAction extends Action2 { - static readonly ID = BrowserViewCommandId.ShowFind; - - constructor() { - super({ - id: ShowBrowserFindAction.ID, - title: localize2('browser.showFindAction', 'Find in Page'), - category: BrowserActionCategory, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Page, - order: 1, - }, - keybinding: { - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyF - } - }); - } - - run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { - if (browserEditor instanceof BrowserEditor) { - browserEditor.showFind(); - } - } -} - -class HideBrowserFindAction extends Action2 { - static readonly ID = BrowserViewCommandId.HideFind; - - constructor() { - super({ - id: HideBrowserFindAction.ID, - title: localize2('browser.hideFindAction', 'Close Find Widget'), - category: BrowserActionCategory, - f1: false, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), - keybinding: { - weight: KeybindingWeight.EditorContrib + 5, - primary: KeyCode.Escape - } - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.hideFind(); - } - } -} - -class BrowserFindNextAction extends Action2 { - static readonly ID = BrowserViewCommandId.FindNext; - - constructor() { - super({ - id: BrowserFindNextAction.ID, - title: localize2('browser.findNextAction', 'Find Next'), - category: BrowserActionCategory, - f1: false, - precondition: BROWSER_EDITOR_ACTIVE, - keybinding: [{ - when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, - weight: KeybindingWeight.EditorContrib, - primary: KeyCode.Enter - }, { - when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, - weight: KeybindingWeight.EditorContrib, - primary: KeyCode.F3, - mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } - }] - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.findNext(); - } - } -} - -class BrowserFindPreviousAction extends Action2 { - static readonly ID = BrowserViewCommandId.FindPrevious; - - constructor() { - super({ - id: BrowserFindPreviousAction.ID, - title: localize2('browser.findPreviousAction', 'Find Previous'), - category: BrowserActionCategory, - f1: false, - precondition: BROWSER_EDITOR_ACTIVE, - keybinding: [{ - when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.Shift | KeyCode.Enter - }, { - when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.Shift | KeyCode.F3, - mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } - }] - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.findPrevious(); - } - } -} - // Register actions -registerAction2(OpenIntegratedBrowserAction); -registerAction2(NewTabAction); registerAction2(GoBackAction); registerAction2(GoForwardAction); registerAction2(ReloadAction); registerAction2(HardReloadAction); -registerAction2(FocusUrlInputAction); -registerAction2(ToggleDevToolsAction); -registerAction2(OpenInExternalBrowserAction); -registerAction2(ClearGlobalBrowserStorageAction); -registerAction2(ClearWorkspaceBrowserStorageAction); -registerAction2(ClearEphemeralBrowserStorageAction); -registerAction2(OpenBrowserSettingsAction); -registerAction2(ShowBrowserFindAction); -registerAction2(HideBrowserFindAction); -registerAction2(BrowserFindNextAction); -registerAction2(BrowserFindPreviousAction); -registerAction2(class ToggleBrowserTitleBarButton extends ToggleTitleBarConfigAction { - constructor() { - super('workbench.browser.showInTitleBar', localize('toggle.browser', 'Integrated Browser'), localize('toggle.browserDescription', "Toggle visibility of the Integrated Browser button in title bar"), 8); - } -}); +registerAction2(FocusUrlInputAction); +registerAction2(OpenInExternalBrowserAction); +registerAction2(OpenBrowserSettingsAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts new file mode 100644 index 00000000000..d3ecac788fc --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { BrowserEditor, BrowserEditorContribution } from '../browserEditor.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../../platform/browserView/common/browserView.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import type { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; + +const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); + +class BrowserEditorStorageScopeContribution extends BrowserEditorContribution { + private readonly _storageScopeContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editor); + this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); + } + + protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + this._storageScopeContext.set(model.storageScope); + } + + override clear(): void { + this._storageScopeContext.reset(); + } +} + +BrowserEditor.registerContribution(BrowserEditorStorageScopeContribution); + +class ClearGlobalBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearGlobalStorage; + + constructor() { + super({ + id: ClearGlobalBrowserStorageAction.ID, + title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearGlobalStorage(); + } +} + +class ClearWorkspaceBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearWorkspaceStorage; + + constructor() { + super({ + id: ClearWorkspaceBrowserStorageAction.ID, + title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearWorkspaceStorage(); + } +} + +class ClearEphemeralBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearEphemeralStorage; + + constructor() { + super({ + id: ClearEphemeralBrowserStorageAction.ID, + title: localize2('browser.clearEphemeralStorageAction', 'Clear Storage (Ephemeral)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.clearStorage(); + } + } +} + +registerAction2(ClearGlobalBrowserStorageAction); +registerAction2(ClearWorkspaceBrowserStorageAction); +registerAction2(ClearEphemeralBrowserStorageAction); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.dataStorage': { + type: 'string', + enum: [ + BrowserViewStorageScope.Global, + BrowserViewStorageScope.Workspace, + BrowserViewStorageScope.Ephemeral + ], + markdownEnumDescriptions: [ + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') + ], + restricted: true, + default: BrowserViewStorageScope.Global, + markdownDescription: localize( + { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, + 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' + ), + scope: ConfigurationScope.WINDOW, + order: 100 + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts new file mode 100644 index 00000000000..c22809d56ee --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory } from '../browserViewActions.js'; + +const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); + +class BrowserEditorDevToolsContribution extends BrowserEditorContribution { + private readonly _devToolsOpenContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editor); + this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + this._devToolsOpenContext.set(model.isDevToolsOpen); + store.add(model.onDidChangeDevToolsState(e => { + this._devToolsOpenContext.set(e.isDevToolsOpen); + })); + } + + override clear(): void { + this._devToolsOpenContext.reset(); + } +} + +BrowserEditor.registerContribution(BrowserEditorDevToolsContribution); + +class ToggleDevToolsAction extends Action2 { + static readonly ID = BrowserViewCommandId.ToggleDevTools; + + constructor() { + super({ + id: ToggleDevToolsAction.ID, + title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), + category: BrowserActionCategory, + icon: Codicon.terminal, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 3, + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.F12 + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.toggleDevTools(); + } + } +} + +registerAction2(ToggleDevToolsAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 793dcc0a2e8..f962e57395f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -45,7 +45,7 @@ import { BrowserActionCategory } from '../browserViewActions.js'; const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); const BrowserCategory = localize2('browserCategory', "Browser"); -export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); +const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); const canShareBrowserWithAgentContext = ContextKeyExpr.and( ChatContextKeys.enabled, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts new file mode 100644 index 00000000000..241f78d087c --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -0,0 +1,408 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { $, getWindow } from '../../../../../base/browser/dom.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Lazy } from '../../../../../base/common/lazy.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { SimpleFindWidget } from '../../../codeEditor/browser/find/simpleFindWidget.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; + +const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); +const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserFindWidgetFocused', false, localize('browser.findWidgetFocused', "Whether the browser find widget is focused")); + +/** + * Find widget for the integrated browser view. + * Uses the SimpleFindWidget base class and communicates with the browser view model + * to perform find operations in the rendered web page. + */ +class BrowserFindWidget extends SimpleFindWidget { + private _model: IBrowserViewModel | undefined; + private readonly _modelDisposables = this._register(new DisposableStore()); + private readonly _findWidgetVisible: IContextKey; + private readonly _findWidgetFocused: IContextKey; + private _lastFindResult: { resultIndex: number; resultCount: number } | undefined; + private _hasFoundMatch = false; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; + + constructor( + container: HTMLElement, + @IContextViewService contextViewService: IContextViewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHoverService hoverService: IHoverService, + @IKeybindingService keybindingService: IKeybindingService, + @IConfigurationService configurationService: IConfigurationService, + @IAccessibilityService accessibilityService: IAccessibilityService + ) { + super({ + showCommonFindToggles: true, + checkImeCompletionState: true, + showResultCount: true, + enableSash: true, + initialWidth: 350, + previousMatchActionId: BrowserViewCommandId.FindPrevious, + nextMatchActionId: BrowserViewCommandId.FindNext, + closeWidgetActionId: BrowserViewCommandId.HideFind + }, contextViewService, contextKeyService, hoverService, keybindingService, configurationService, accessibilityService); + + this._findWidgetVisible = CONTEXT_BROWSER_FIND_WIDGET_VISIBLE.bindTo(contextKeyService); + this._findWidgetFocused = CONTEXT_BROWSER_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); + + const domNode = this.getDomNode(); + container.appendChild(domNode); + + let lastHeight = domNode.offsetHeight; + const resizeObserver = new (getWindow(container).ResizeObserver)(() => { + const newHeight = domNode.offsetHeight; + if (newHeight !== lastHeight) { + lastHeight = newHeight; + this._onDidChangeHeight.fire(); + } + }); + resizeObserver.observe(domNode); + this._register(toDisposable(() => resizeObserver.disconnect())); + } + + /** + * Set the browser view model to use for find operations. + * This should be called whenever the editor input changes. + */ + setModel(model: IBrowserViewModel | undefined): void { + this._modelDisposables.clear(); + this._model = model; + this._lastFindResult = undefined; + this._hasFoundMatch = false; + + if (model) { + this._modelDisposables.add(model.onDidFindInPage(result => { + this._lastFindResult = { + resultIndex: result.activeMatchOrdinal - 1, // Convert to 0-based index + resultCount: result.matches + }; + this._hasFoundMatch = result.matches > 0; + this.updateButtons(this._hasFoundMatch); + this.updateResultCount(); + })); + + this._modelDisposables.add(model.onWillDispose(() => { + this.setModel(undefined); + })); + } + } + + override reveal(initialInput?: string): void { + const wasVisible = this.isVisible(); + super.reveal(initialInput); + this._findWidgetVisible.set(true); + + // Focus the find input + this.focusFindBox(); + + // If there's existing input and the widget wasn't already visible, trigger a search + if (this.inputValue && !wasVisible) { + this._onInputChanged(); + } + } + + override hide(): void { + super.hide(false); + this._findWidgetVisible.reset(); + + // Stop find and clear highlights in the browser view + this._model?.stopFindInPage(true); + this._model?.focus(); + this._lastFindResult = undefined; + this._hasFoundMatch = false; + } + + find(previous: boolean): void { + const value = this.inputValue; + if (value && this._model) { + this._model.findInPage(value, { + forward: !previous, + recompute: false, + matchCase: this._getCaseSensitiveValue() + }); + } + } + + findFirst(): void { + const value = this.inputValue; + if (value && this._model) { + this._model.findInPage(value, { + forward: true, + recompute: true, + matchCase: this._getCaseSensitiveValue() + }); + } + } + + clear(): void { + if (this._model) { + this._model.stopFindInPage(false); + this._lastFindResult = undefined; + this._hasFoundMatch = false; + } + } + + protected _onInputChanged(): boolean { + if (this.inputValue) { + this.findFirst(); + } else if (this._model) { + this.clear(); + } + return false; + } + + protected async _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined> { + return this._lastFindResult; + } + + protected _onFocusTrackerFocus(): void { + this._findWidgetFocused.set(true); + } + + protected _onFocusTrackerBlur(): void { + this._findWidgetFocused.reset(); + } + + protected _onFindInputFocusTrackerFocus(): void { + // No-op + } + + protected _onFindInputFocusTrackerBlur(): void { + // No-op + } +} + +/** + * Browser editor contribution that manages the find-in-page widget. + * + * Creates a container just below the toolbar and lazily instantiates the + * {@link BrowserFindWidget}. When the find widget's height changes the + * browser container is re-laid-out so that the web-contents view stays in + * sync. + */ +export class BrowserEditorFindContribution extends BrowserEditorContribution { + private readonly _findWidgetContainer: HTMLElement; + private readonly _findWidget: Lazy; + + constructor( + editor: BrowserEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(editor); + + this._findWidgetContainer = $('.browser-find-widget-wrapper'); + + this._findWidget = new Lazy(() => { + const findWidget = this.instantiationService.createInstance( + BrowserFindWidget, + this._findWidgetContainer + ); + if (editor.model) { + findWidget.setModel(editor.model); + } + findWidget.onDidChangeHeight(() => { + editor.layoutBrowserContainer(); + }); + return findWidget; + }); + this._register(toDisposable(() => this._findWidget.rawValue?.dispose())); + } + + /** + * The container element to insert below the toolbar. + */ + override get toolbarElements(): readonly HTMLElement[] { + return [this._findWidgetContainer]; + } + + protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + this._findWidget.rawValue?.setModel(model); + } + + override clear(): void { + this._findWidget.rawValue?.setModel(undefined); + this._findWidget.rawValue?.hide(); + } + + override layout(width: number): void { + this._findWidget.rawValue?.layout(width); + } + + /** + * Show the find widget, optionally pre-populated with selected text from the browser view + */ + async showFind(): Promise { + const selectedText = (await this.editor.model?.getSelectedText())?.trim(); + const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; + this._findWidget.value.reveal(textToReveal); + this._findWidget.value.layout(this._findWidgetContainer.clientWidth); + } + + /** + * Hide the find widget + */ + hideFind(): void { + this._findWidget.rawValue?.hide(); + } + + /** + * Find the next match + */ + findNext(): void { + this._findWidget.rawValue?.find(false); + } + + /** + * Find the previous match + */ + findPrevious(): void { + this._findWidget.rawValue?.find(true); + } +} + +BrowserEditor.registerContribution(BrowserEditorFindContribution); + +// -- Actions ---------------------------------------------------------------- + +class ShowBrowserFindAction extends Action2 { + static readonly ID = BrowserViewCommandId.ShowFind; + + constructor() { + super({ + id: ShowBrowserFindAction.ID, + title: localize2('browser.showFindAction', 'Find in Page'), + category: BrowserActionCategory, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Page, + order: 1, + }, + keybinding: { + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyF + } + }); + } + + run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { + if (browserEditor instanceof BrowserEditor) { + void browserEditor.getContribution(BrowserEditorFindContribution)?.showFind(); + } + } +} + +class HideBrowserFindAction extends Action2 { + static readonly ID = BrowserViewCommandId.HideFind; + + constructor() { + super({ + id: HideBrowserFindAction.ID, + title: localize2('browser.hideFindAction', 'Close Find Widget'), + category: BrowserActionCategory, + f1: false, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), + keybinding: { + weight: KeybindingWeight.EditorContrib + 5, + primary: KeyCode.Escape + } + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.hideFind(); + } + } +} + +class BrowserFindNextAction extends Action2 { + static readonly ID = BrowserViewCommandId.FindNext; + + constructor() { + super({ + id: BrowserFindNextAction.ID, + title: localize2('browser.findNextAction', 'Find Next'), + category: BrowserActionCategory, + f1: false, + precondition: BROWSER_EDITOR_ACTIVE, + keybinding: [{ + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Enter + }, { + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.F3, + mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } + }] + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.findNext(); + } + } +} + +class BrowserFindPreviousAction extends Action2 { + static readonly ID = BrowserViewCommandId.FindPrevious; + + constructor() { + super({ + id: BrowserFindPreviousAction.ID, + title: localize2('browser.findPreviousAction', 'Find Previous'), + category: BrowserActionCategory, + f1: false, + precondition: BROWSER_EDITOR_ACTIVE, + keybinding: [{ + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.Shift | KeyCode.Enter + }, { + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.Shift | KeyCode.F3, + mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } + }] + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.findPrevious(); + } + } +} + +registerAction2(ShowBrowserFindAction); +registerAction2(HideBrowserFindAction); +registerAction2(BrowserFindNextAction); +registerAction2(BrowserFindPreviousAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts index 881467ec8e7..a527d8ab6c4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts @@ -30,8 +30,8 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/in import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; -export const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); -export const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); +const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); +const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); /** * Transient zoom-level indicator that briefly appears inside the URL bar on zoom changes. diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts new file mode 100644 index 00000000000..dc391f59251 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -0,0 +1,487 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; +import { IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js'; +import { EditorsOrder, GroupIdentifier } from '../../../../common/editor.js'; +import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPickSeparator, QuickInputButtonLocation, IQuickPick } from '../../../../../platform/quickinput/common/quickInput.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import { logBrowserOpen } from '../../../../../platform/browserView/common/browserViewTelemetry.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { isLocalhostAuthority } from '../../../../../platform/url/common/trustedDomains.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; + +const CONTEXT_BROWSER_EDITOR_OPEN = new RawContextKey('browserEditorOpen', false, localize('browser.editorOpen', "Whether any browser editor is currently open")); + +interface IBrowserQuickPickItem extends IQuickPickItem { + groupId: GroupIdentifier; + editor: BrowserEditorInput; +} + +const closeButtonItem: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.close), + tooltip: localize('browser.closeTab', "Close") +}; + +const closeAllButtonItem: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.closeAll), + tooltip: localize('browser.closeAllTabs', "Close All"), + location: QuickInputButtonLocation.Inline +}; + + +/** + * Manages a quick pick that lists all open browser tabs grouped by editor group, + * with close buttons, live updates, and an always-visible "New Integrated Browser Tab" entry. + */ +class BrowserTabQuickPick extends Disposable { + + private readonly _quickPick: IQuickPick; + private readonly _itemListeners = this._register(new DisposableStore()); + + private readonly _openNewTabPick: IBrowserQuickPickItem = { + groupId: -1, + editor: undefined!, + label: localize('browser.openNewTab', "New Integrated Browser Tab"), + iconClass: ThemeIcon.asClassName(Codicon.add), + alwaysShow: true, + }; + + constructor( + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IQuickInputService quickInputService: IQuickInputService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(); + + this._quickPick = this._register(quickInputService.createQuickPick({ useSeparators: true })); + this._quickPick.placeholder = localize('browser.quickOpenPlaceholder', "Select a browser tab or enter a URL"); + this._quickPick.matchOnDescription = true; + this._quickPick.sortByLabel = false; + this._quickPick.buttons = [closeAllButtonItem]; + + this._register(this._quickPick.onDidTriggerItemButton(async ({ item }) => { + if (!item.editor) { + return; + } + const group = this._editorGroupsService.getGroup(item.groupId); + if (group) { + await group.closeEditor(item.editor, { + preserveFocus: true // Don't shift focus so the quickpick doesn't close + }); + } + })); + + this._register(this._quickPick.onDidTriggerButton(async () => { + for (const group of this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + const browserEditors = group.editors.filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors, { + preserveFocus: true // Don't shift focus so the quickpick doesn't close + }); + } + } + })); + + this._register(this._quickPick.onDidAccept(async () => { + const [selected] = this._quickPick.selectedItems; + if (!selected) { + return; + } + if (selected === this._openNewTabPick) { + logBrowserOpen(telemetryService, 'quickOpenWithoutUrl'); + await this._editorService.openEditor({ + resource: BrowserViewUri.forId(generateUuid()), + }); + } else { + await this._editorService.openEditor(selected.editor, selected.groupId); + } + })); + + this._register(this._quickPick.onDidHide(() => this.dispose())); + } + + show(): void { + this._buildItems(); + + // Pre-select the currently active browser editor + const activeEditor = this._editorService.activeEditor; + if (activeEditor instanceof BrowserEditorInput) { + const activePick = (this._quickPick.items as readonly (IBrowserQuickPickItem | IQuickPickSeparator)[]) + .find((item): item is IBrowserQuickPickItem => item.type !== 'separator' && item.editor === activeEditor); + if (activePick) { + this._quickPick.activeItems = [activePick]; + } + } + + this._quickPick.show(); + } + + private _buildItems(): void { + this._itemListeners.clear(); + + // Remember which editor was active so we can restore selection + const activeEditor = this._quickPick.activeItems[0]?.editor; + + const picks: (IBrowserQuickPickItem | IQuickPickSeparator)[] = []; + const groups = this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE); + + const groupsWithBrowserEditors = groups + .map(group => ({ group, browserEditors: group.editors.filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput) })) + .filter(({ browserEditors }) => browserEditors.length > 0); + const multipleGroups = groupsWithBrowserEditors.length > 1; + + // Build a map of group ID to aria label for screen readers + const mapGroupIdToGroupAriaLabel = new Map(); + for (const { group } of groupsWithBrowserEditors) { + mapGroupIdToGroupAriaLabel.set(group.id, group.ariaLabel); + } + + let newActivePick: IBrowserQuickPickItem | undefined; + + for (const { group, browserEditors } of groupsWithBrowserEditors) { + if (multipleGroups) { + picks.push({ type: 'separator', label: group.label }); + } + for (const editor of browserEditors) { + const icon = editor.getIcon(); + const description = editor.getDescription(); + const nameAndDescription = description ? `${editor.getName()} ${description}` : editor.getName(); + const pick: IBrowserQuickPickItem = { + groupId: group.id, + editor, + label: editor.getName(), + ariaLabel: multipleGroups + ? localize('browserEntryAriaLabelWithGroup', "{0}, {1}", nameAndDescription, mapGroupIdToGroupAriaLabel.get(group.id)) + : nameAndDescription, + description, + buttons: [closeButtonItem], + italic: !group.isPinned(editor), + }; + if (icon instanceof URI) { + pick.iconPath = { dark: icon }; + } else if (icon) { + pick.iconClass = ThemeIcon.asClassName(icon); + } + picks.push(pick); + + if (editor === activeEditor) { + newActivePick = pick; + } + + this._itemListeners.add(editor.onDidChangeLabel(() => this._buildItems())); + } + this._itemListeners.add(group.onDidModelChange(() => this._buildItems())); + } + + picks.push({ type: 'separator' }); + picks.push(this._openNewTabPick); + + this._quickPick.keepScrollPosition = true; + this._quickPick.items = picks; + if (newActivePick) { + this._quickPick.activeItems = [newActivePick]; + } + } +} + +class QuickOpenBrowserAction extends Action2 { + constructor() { + const neverShowInTitleBar = ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', false); + super({ + id: BrowserViewCommandId.QuickOpen, + title: localize2('browser.quickOpenAction', "Quick Open Browser Tab..."), + icon: Codicon.globe, + category: BrowserActionCategory, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + // Note: on Linux this conflicts with the "toggle block comment" keybinding. + // it's not as problem at the moment becase oh the `when`, but worth noting for the future. + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + when: BROWSER_EDITOR_ACTIVE + }, + menu: { + id: MenuId.TitleBar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and(CONTEXT_BROWSER_EDITOR_OPEN, neverShowInTitleBar.negate()), + } + }); + } + + run(accessor: ServicesAccessor): void { + const picker = accessor.get(IInstantiationService).createInstance(BrowserTabQuickPick); + picker.show(); + } +} + +interface IOpenBrowserOptions { + url?: string; + openToSide?: boolean; +} + +class OpenIntegratedBrowserAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.Open, + title: localize2('browser.openAction', "Open Integrated Browser"), + category: BrowserActionCategory, + icon: Codicon.globe, + f1: true, + menu: { + id: MenuId.TitleBar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and( + // This is a hack to work around `true` just testing for truthiness of the key. It works since `1 == true` in JS. + ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', 1), + CONTEXT_BROWSER_EDITOR_OPEN.negate() + ) + } + }); + } + + async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { + const editorService = accessor.get(IEditorService); + const telemetryService = accessor.get(ITelemetryService); + + // Parse arguments + const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); + const resource = BrowserViewUri.forId(generateUuid()); + const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP; + + logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); + + const editorPane = await editorService.openEditor({ resource, options: { viewState: { url: options.url } } }, group); + + // Lock the group when opening to the side + if (options.openToSide && editorPane?.group) { + editorPane.group.lock(true); + } + } +} + +class NewTabAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.NewTab, + title: localize2('browser.newTabAction', "New Tab"), + category: BrowserActionCategory, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Tabs, + order: 1, + }, + // When already in a browser, Ctrl/Cmd + T opens a new tab + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions + primary: KeyMod.CtrlCmd | KeyCode.KeyT, + } + }); + } + + async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + const editorService = accessor.get(IEditorService); + const telemetryService = accessor.get(ITelemetryService); + const resource = BrowserViewUri.forId(generateUuid()); + + logBrowserOpen(telemetryService, 'newTabCommand'); + + await editorService.openEditor({ resource }); + } +} + +class CloseAllBrowserTabsAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.CloseAll, + title: localize2('browser.closeAll', "Close All Browser Tabs"), + category: BrowserActionCategory, + f1: true, + precondition: CONTEXT_BROWSER_EDITOR_OPEN, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + for (const group of editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors); + } + } + } +} + +class CloseAllBrowserTabsInGroupAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.CloseAllInGroup, + title: localize2('browser.closeAllInGroup', "Close All Browser Tabs in Group"), + category: BrowserActionCategory, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); + const group = editorGroupsService.getGroup(editorService.activeEditorPane?.group?.id ?? editorGroupsService.activeGroup.id); + if (!group) { + return; + } + const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors); + } + } +} + +// Register as "Close All Browser Tabs" action in editor title menu to align with the regular "Close All" action +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: BrowserViewCommandId.CloseAllInGroup, title: localize('browser.closeAllInGroupShort', "Close All Browser Tabs") }, group: '1_close', order: 55, when: BROWSER_EDITOR_ACTIVE }); + +registerAction2(QuickOpenBrowserAction); +registerAction2(OpenIntegratedBrowserAction); +registerAction2(NewTabAction); +registerAction2(CloseAllBrowserTabsAction); +registerAction2(CloseAllBrowserTabsInGroupAction); + +registerAction2(class ToggleBrowserTitleBarButton extends ToggleTitleBarConfigAction { + constructor() { + super('workbench.browser.showInTitleBar', localize('toggle.browser', 'Integrated Browser'), localize('toggle.browserDescription', "Toggle visibility of the Integrated Browser button in title bar"), 8); + } +}); + +/** + * Tracks whether any browser editor is open across all editor groups and + * keeps the `browserEditorOpen` context key in sync. + */ +class BrowserEditorOpenContextKeyContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.browserEditorOpenContextKey'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IEditorService editorService: IEditorService, + ) { + super(); + + const contextKey = CONTEXT_BROWSER_EDITOR_OPEN.bindTo(contextKeyService); + const update = () => contextKey.set(editorService.editors.some(e => e instanceof BrowserEditorInput)); + + update(); + + this._register(editorService.onWillOpenEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + contextKey.set(true); + } + })); + this._register(editorService.onDidCloseEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + update(); + } + })); + } +} + +registerWorkbenchContribution2(BrowserEditorOpenContextKeyContribution.ID, BrowserEditorOpenContextKeyContribution, WorkbenchPhase.AfterRestored); + +/** + * Opens localhost URLs in the Integrated Browser when the setting is enabled. + */ +class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IExternalOpener { + static readonly ID = 'workbench.contrib.localhostLinkOpener'; + + constructor( + @IOpenerService openerService: IOpenerService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorService private readonly editorService: IEditorService, + @ITelemetryService private readonly telemetryService: ITelemetryService + ) { + super(); + + this._register(openerService.registerExternalOpener(this)); + } + + async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { + if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { + return false; + } + + try { + const parsed = new URL(href); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + if (!isLocalhostAuthority(parsed.host)) { + return false; + } + } catch { + return false; + } + + logBrowserOpen(this.telemetryService, 'localhostLinkOpener'); + + const browserUri = BrowserViewUri.forId(generateUuid()); + await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href } } }); + return true; + } +} + +registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.showInTitleBar': { + type: ['boolean', 'string'], + enum: [true, false, 'whenOpen'], + enumDescriptions: [ + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.true' }, 'The button is always shown in the title bar.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.false' }, 'The button is never shown in the title bar.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.whenOpen' }, 'The button is shown in the title bar when a browser editor is open.') + ], + default: 'whenOpen', + experiment: { mode: 'startup' }, + description: localize( + { comment: ['This is the description for a setting.'], key: 'browser.showInTitleBar' }, + 'Controls whether the Integrated Browser button is shown in the title bar.' + ) + }, + 'workbench.browser.openLocalhostLinks': { + type: 'boolean', + default: false, + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, + 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' + ) + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 929d720d448..f5856137557 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -30,7 +30,7 @@ width: 100%; height: 100%; - .browser-toolbar { + .browser-navbar { display: flex; align-items: center; padding: 6px 8px; @@ -365,10 +365,6 @@ z-index: 10; overflow: hidden; - &.find-visible { - border-bottom: 1px solid var(--vscode-widget-border); - } - /* Override SimpleFindWidget absolute positioning to flow in layout */ .simple-find-part-wrapper { position: relative; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 19984144472..f33a0d8cba9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -56,6 +56,7 @@ import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableE import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { AICustomizationManagementCommands } from '../aiCustomization/aiCustomizationManagement.js'; import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; @@ -1435,6 +1436,12 @@ export function registerChatActions() { id: MenuId.ChatWelcomeContext, group: '2_settings', order: 1 + }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 15, + group: '3_configure' }] }); } @@ -1445,11 +1452,29 @@ export function registerChatActions() { } }); + // When customizations menu is enabled, show a direct gear action to open the Customizations editor + MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + command: { + id: AICustomizationManagementCommands.OpenEditor, + title: localize2('openChatCustomizations', "Open Customizations"), + category: CHAT_CATEGORY, + icon: Codicon.gear + }, + group: 'navigation', + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.equals('view', ChatViewId), + ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`) + ), + order: 6 + }); + + // When customizations menu is disabled, show the legacy gear submenu MenuRegistry.appendMenuItem(MenuId.ViewTitle, { submenu: CHAT_CONFIG_MENU_ID, title: localize2('config.label', "Configure Chat"), group: 'navigation', - when: ContextKeyExpr.equals('view', ChatViewId), + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`).negate()), icon: Codicon.gear, order: 6 }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 263c8839baf..d6522e25c2a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -14,6 +14,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -22,9 +23,11 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; +import { ChatConfiguration } from '../../common/constants.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; /** * Registers the Open Agent Debug Logs and Show Agent Debug Logs actions. @@ -71,6 +74,11 @@ export function registerChatOpenAgentDebugPanelAction() { group: '2_settings', order: 0, when: ChatContextKeys.inChatEditor.negate() + }, { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 0, + group: '4_logs' }] }); } @@ -127,6 +135,7 @@ export function registerChatOpenAgentDebugPanelAction() { const fileDialogService = accessor.get(IFileDialogService); const fileService = accessor.get(IFileService); const notificationService = accessor.get(INotificationService); + const openerService = accessor.get(IOpenerService); const telemetryService = accessor.get(ITelemetryService); const sessionResource = chatDebugService.activeSessionResource; @@ -135,7 +144,11 @@ export function registerChatOpenAgentDebugPanelAction() { return; } - const defaultUri = joinPath(await fileDialogService.defaultFilePath(), defaultDebugLogFileName); + const localSessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); + const rawIdentifier = localSessionId ?? (sessionResource.path.replace(/^\//, '') || sessionResource.authority); + const sessionIdentifier = rawIdentifier?.replace(/[/\\:*?"<>|.]+/g, '_').replace(/^_+|_+$/g, ''); + const exportFileName = sessionIdentifier ? `agent-debug-log-${sessionIdentifier}.json` : defaultDebugLogFileName; + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), exportFileName); const outputPath = await fileDialogService.showSaveDialog({ defaultUri, filters: debugLogFilters }); if (!outputPath) { return; @@ -152,6 +165,15 @@ export function registerChatOpenAgentDebugPanelAction() { telemetryService.publicLog2('chatDebugLogExported', { fileSizeBytes: data.byteLength, }); + + notificationService.prompt( + Severity.Info, + localize('chatDebugLog.exportSuccess', "Agent debug log exported successfully."), + [{ + label: localize('chatDebugLog.openExportedFile', "Open File"), + run: () => openerService.open(outputPath) + }] + ); } }); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 92d569bc9be..155b5b23178 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -286,13 +286,27 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } } - async cleanupPluginSource(plugin: IMarketplacePlugin): Promise { + async cleanupPluginSource(plugin: IMarketplacePlugin, otherInstalledDescriptors?: readonly IPluginSourceDescriptor[]): Promise { const repo = this.getPluginSource(plugin.sourceDescriptor.kind); const cleanupDir = repo.getCleanupTarget(this._cacheRoot, plugin.sourceDescriptor); if (!cleanupDir) { return; } + // Skip deletion when another installed plugin shares the same + // cleanup target (e.g. same cloned repository with different sub-paths). + if (otherInstalledDescriptors) { + const shared = otherInstalledDescriptors.some(other => { + const otherRepo = this.getPluginSource(other.kind); + const otherTarget = otherRepo.getCleanupTarget(this._cacheRoot, other); + return otherTarget && isEqual(otherTarget, cleanupDir); + }); + if (shared) { + this._logService.info(`[${plugin.sourceDescriptor.kind}] Skipping cleanup of shared cache: ${cleanupDir.toString()}`); + return; + } + } + try { const exists = await this._fileService.exists(cleanupDir); if (exists) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 55109bc513f..c87e45bead2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -219,6 +219,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr fullName: agent.displayName, description: agent.description, connection: this._agentHostService, + resolveAuthentication: () => this._resolveAuthenticationInteractively(), })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -233,27 +234,97 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); // Push auth token and refresh models from server - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); store.add(this._authenticationService.onDidChangeSessions(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); } - private async _pushAuthToken(): Promise { + /** + * Discover auth requirements from the server's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithServer(): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await this._agentHostService.getResourceMetadata(); + this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`); + for (const resource of metadata.resources) { + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); + await this._agentHostService.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); + } } - - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await this._agentHostService.setAuthToken(session.accessToken); - } - } catch { - // best-effort + } catch (err) { + this._logService.error('[AgentHost] Failed to authenticate with server', err); } } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); + if (!providerId) { + this._logService.trace(`[AgentHost] No auth provider found for server: ${server}`); + continue; + } + this._logService.trace(`[AgentHost] Resolved auth provider '${providerId}' for server: ${server}`); + + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + this._logService.trace(`[AgentHost] No sessions found for provider '${providerId}'`); + } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Fetches resource metadata, resolves the auth provider, creates a session + * (which triggers the login UI), and pushes the token to the server. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(): Promise { + try { + const metadata = await this._agentHostService.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const resourceUri = URI.parse(resource.resource); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); + if (!providerId) { + continue; + } + + // createSession will show the login UI if no session exists + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await this._agentHostService.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[AgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[AgentHost] Interactive authentication failed', err); + } + return false; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 1cbfe0e23be..c12d76c2766 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../nls.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -17,6 +18,7 @@ import { IProductService } from '../../../../../../platform/product/common/produ import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentAttachment, AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType, isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { AttachmentType, ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -96,6 +98,12 @@ export interface IAgentHostSessionHandlerConfig { * If not provided, falls back to the first workspace folder. */ readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; + /** + * Optional callback invoked when the server rejects an operation because + * authentication is required. Should trigger interactive authentication + * and return true if the user authenticated successfully. + */ + readonly resolveAuthentication?: () => Promise; } export class AgentHostSessionHandler extends Disposable implements IChatSessionContentProvider { @@ -442,11 +450,33 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ?? this._workspaceContextService.getWorkspace().folders[0]?.uri.fsPath; this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}`); - const session = await this._config.connection.createSession({ - model: rawModelId, - provider: this._config.provider, - workingDirectory, - }); + + let session: URI; + try { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } catch (err) { + // If authentication is required, try to resolve it and retry once + if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) { + this._logService.info('[AgentHost] Authentication required, prompting user...'); + const authenticated = await this._config.resolveAuthentication(); + if (authenticated) { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } else { + throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again.")); + } + } else { + throw err; + } + } + this._logService.trace(`[AgentHost] Created session: ${session.toString()}`); // Subscribe to the new session's state @@ -460,6 +490,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return session; } + /** + * Check if an error is an "authentication required" error. + * Checks for the AHP_AUTH_REQUIRED error code when available, + * with a message-based fallback for transports that don't preserve + * structured error codes (e.g. ProxyChannel). + */ + private _isAuthRequiredError(err: unknown): boolean { + if (err instanceof ProtocolError && err.code === AHP_AUTH_REQUIRED) { + return true; + } + if (err instanceof Error && err.message.includes('Authentication required')) { + return true; + } + return false; + } + /** * Extracts the raw model id from a language-model service identifier. * E.g. "agent-host-copilot:claude-sonnet-4-20250514" → "claude-sonnet-4-20250514". diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index cf43a98126c..095117618df 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -41,12 +41,14 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._register(this._connection.onDidNotification(n => { if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) { const rawId = AgentSession.id(n.summary.resource); + const workingDir = typeof n.summary.workingDirectory === 'string' ? n.summary.workingDirectory : undefined; const item: IChatSessionItem = { resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), label: n.summary.title ?? `Session ${rawId.substring(0, 8)}`, description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(workingDir), timing: { created: n.summary.createdAt, lastRequestStarted: n.summary.modifiedAt, @@ -89,6 +91,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(s.workingDirectory), timing: { created: s.startTime, lastRequestStarted: s.modifiedTime, @@ -100,4 +103,15 @@ export class AgentHostSessionListController extends Disposable implements IChatS } this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); } + + private _buildMetadata(workingDirectory?: string): { readonly [key: string]: unknown } | undefined { + if (!this._description) { + return undefined; + } + const result: { [key: string]: unknown } = { remoteAgentHost: this._description }; + if (workingDirectory) { + result.workingDirectoryPath = workingDirectory; + } + return result; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 4000cefb73b..61ef7f44865 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -16,7 +16,7 @@ import { IAgentSessionsService, AgentSessionsService } from './agentSessionsServ import { LocalAgentsSessionsController } from './localAgentSessionsController.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction } from './agentSessionsActions.js'; +import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction, CollapseAllAgentSessionSectionsAction } from './agentSessionsActions.js'; import { AgentSessionsQuickAccessProvider, AGENT_SESSIONS_QUICK_ACCESS_PREFIX } from './agentSessionsQuickAccess.js'; //#region Actions and Menus @@ -28,6 +28,7 @@ registerAction2(MarkAllAgentSessionsReadAction); registerAction2(ArchiveAgentSessionSectionAction); registerAction2(UnarchiveAgentSessionSectionAction); registerAction2(MarkAgentSessionSectionReadAction); +registerAction2(CollapseAllAgentSessionSectionsAction); registerAction2(ArchiveAgentSessionAction); registerAction2(UnarchiveAgentSessionAction); registerAction2(PinAgentSessionAction); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index f01ebe74835..b58ce96c3c3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -21,6 +21,14 @@ export enum AgentSessionProviders { AgentHostCopilot = 'agent-host-copilot', } +/** + * A session target is either a well-known {@link AgentSessionProviders} enum + * value or a dynamic string for dynamically-registered providers (e.g. remote + * agent hosts like `remote-{authority}-copilot`). + * TODO@roblourens HACK + */ +export type AgentSessionTarget = AgentSessionProviders | (string & {}); + export function isBuiltInAgentSessionProvider(provider: string): boolean { return provider === AgentSessionProviders.Local || provider === AgentSessionProviders.Background || @@ -171,6 +179,9 @@ export interface IAgentSessionsControl { clearFocus(): void; hasFocusOrSelection(): boolean; + + resetSectionCollapseState(): void; + collapseAllSections(): void; } export const agentSessionReadIndicatorForeground = registerColor( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index c7d0360d000..b501f0a66ba 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -365,6 +365,25 @@ export class MarkAgentSessionSectionReadAction extends Action2 { } } +export class CollapseAllAgentSessionSectionsAction extends Action2 { + + constructor() { + super({ + id: 'agentSessionSection.collapseAll', + title: localize2('collapseAll', "Collapse All"), + menu: [{ + id: MenuId.AgentSessionSectionContext, + group: '2_collapse', + order: 1, + }] + }); + } + + async run(accessor: ServicesAccessor, _section: unknown, control?: IAgentSessionsControl): Promise { + control?.collapseAllSections(); + } +} + //#endregion //#region Session Actions diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1130ad7925e..1f073a38862 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -13,8 +13,8 @@ import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; -import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter } from './agentSessionsViewer.js'; -import { AgentSessionsGrouping } from './agentSessionsFilter.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -42,7 +42,6 @@ import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { IChatWidget } from '../chat.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; export interface IAgentSessionsControlOptions { readonly overrideStyles: IStyleOverride; @@ -80,8 +79,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private emptyFilterMessage: HTMLElement | undefined; private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; + private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; + private sessionsListFindIsOpen = false; private _isProgrammaticCollapseChange = false; + private readonly _recentRepositoryLabels = new Set(); private readonly updateSessionsListThrottler = this._register(new Throttler()); @@ -109,7 +111,6 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, @IStorageService private readonly storageService: IStorageService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -215,6 +216,10 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.storageService.store(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); } + resetSectionCollapseState(): void { + this.storageService.remove(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + } + private createList(container: HTMLElement): void { const collapseByDefault = (element: unknown) => { if (isAgentSessionSection(element)) { @@ -238,20 +243,24 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo if (element.section === AgentSessionSection.Yesterday && this.hasTodaySessions()) { return true; // Also collapse Yesterday when there are sessions from Today } + if (element.section === AgentSessionSection.Repository && !this._recentRepositoryLabels.has(element.label)) { + return true; // Collapse repository sections that don't contain recent sessions + } } } return false; }; - const sorter = new AgentSessionsSorter(); + const sorter = new AgentSessionsSorter(() => this.options.filter.sortResults?.() ?? AgentSessionsSorting.Created); const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined; const activeSessionResource = observableValue(this, undefined); const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, { ...this.options, isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, + isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, }, approvalModel, activeSessionResource)); - const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter, this.logService)); + const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', container, @@ -305,6 +314,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); + this.computeRecentRepositoryLabels(); list.setInput(model); this._register(list.onDidOpen(e => this.openAgentSession(e))); @@ -376,6 +386,20 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo ); } + private computeRecentRepositoryLabels(): void { + this._recentRepositoryLabels.clear(); + + const sessions = this.agentSessionsService.model.sessions + .filter(s => !s.isArchived() && !s.isPinned()) + .sort((a, b) => b.timing.created - a.timing.created) + .slice(0, AgentSessionsControl.RECENT_SESSIONS_FOR_EXPAND); + + for (const session of sessions) { + const name = getRepositoryName(session); + this._recentRepositoryLabels.add(name ?? AgentSessionSectionLabels[AgentSessionSection.Repository]); + } + } + private async openAgentSession(e: IOpenEvent): Promise { const element = e.element; if (!element || isAgentSessionSection(element)) { @@ -421,7 +445,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.contextMenuService.showContextMenu({ getActions: () => Separator.join(...menu.getActions({ arg: section, shouldForwardArgs: true }).map(([, actions]) => actions)), getAnchor: () => anchor, - getActionsContext: () => section, + getActionsContext: () => this, }); menu.dispose(); @@ -509,8 +533,22 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return this.agentSessionsService.model.resolve(undefined); } + collapseAllSections(): void { + if (!this.sessionsList) { + return; + } + + const model = this.agentSessionsService.model; + for (const child of this.sessionsList.getNode(model).children) { + if (isAgentSessionSection(child.element) && !child.collapsed) { + this.sessionsList.collapse(child.element); + } + } + } + async update(): Promise { return this.updateSessionsListThrottler.queue(async () => { + this.computeRecentRepositoryLabels(); await this.sessionsList?.updateChildren(); this._onDidUpdate.fire(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 5cb399a6c87..eba6abdd656 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -21,6 +21,11 @@ export enum AgentSessionsGrouping { Repository = 'repository' } +export enum AgentSessionsSorting { + Created = 'created', + Updated = 'updated' +} + export interface IAgentSessionsFilterOptions extends Partial { readonly filterMenuId?: MenuId; @@ -41,6 +46,7 @@ export interface IAgentSessionsFilterOptions extends Partial AgentSessionsGrouping | undefined; + readonly sortResults?: () => AgentSessionsSorting | undefined; overrideExclude?(session: IAgentSession): boolean | undefined; } @@ -61,6 +67,7 @@ export class AgentSessionsFilter extends Disposable implements Required this.options.limitResults?.(); readonly groupResults = () => this.options.groupResults?.(); + readonly sortResults = () => this.options.sortResults?.(); private excludes = DEFAULT_EXCLUDES; private isStoringExcludes = false; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 6eed12abc64..369ad0e89b1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -42,15 +42,13 @@ import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer. import { MarkdownString, IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js'; import { AgentSessionProviders } from './agentSessions.js'; -import { AgentSessionsGrouping } from './agentSessionsFilter.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { autorun, IObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; - export type AgentSessionListItem = IAgentSession | IAgentSessionSection; @@ -90,7 +88,9 @@ interface IAgentSessionItemTemplate { export interface IAgentSessionRendererOptions { readonly disableHover?: boolean; getHoverPosition(): HoverPosition; + isGroupedByRepository?(): boolean; + isSortedByUpdated?(): boolean; } export class AgentSessionRenderer extends Disposable implements ICompressibleTreeRenderer { @@ -416,7 +416,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } if (!timeLabel) { - const date = session.timing.created; + const date = this.options.isSortedByUpdated?.() + ? session.timing.lastRequestEnded ?? session.timing.created + : session.timing.created; const seconds = Math.round((new Date().getTime() - date) / 1000); if (seconds < 60) { timeLabel = localize('secondsDuration', "now"); @@ -571,6 +573,7 @@ export function toStatusLabel(status: AgentSessionStatus): string { interface IAgentSessionSectionTemplate { readonly container: HTMLElement; readonly label: HTMLSpanElement; + readonly count: HTMLSpanElement; readonly toolbar: MenuWorkbenchToolBar; readonly contextKeyService: IContextKeyService; readonly disposables: IDisposable; @@ -594,6 +597,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer AgentSessionsGrouping | undefined; + /** + * The field to sort sessions by. + * Defaults to created date when undefined. + */ + readonly sortResults?: () => AgentSessionsSorting | undefined; + /** * A callback to notify the filter about the number of * results after filtering. @@ -760,7 +774,6 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, - private readonly logService?: ILogService, ) { super(); } @@ -881,7 +894,8 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou private groupSessionsByDate(sortedSessions: IAgentSession[]): AgentSessionListItem[] { const result: AgentSessionListItem[] = []; - const groupedSessions = groupAgentSessionsByDate(sortedSessions); + const sortBy = this.filter?.sortResults?.(); + const groupedSessions = groupAgentSessionsByDate(sortedSessions, sortBy); for (const { sessions, section, label } of groupedSessions.values()) { if (sessions.length === 0) { @@ -898,8 +912,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const repoMap = new Map(); const pinnedSessions: IAgentSession[] = []; const archivedSessions: IAgentSession[] = []; - const unknownKey = '\x00unknown'; - const unknownLabel = localize('agentSessions.noRepository', "Other"); + const otherSessions: IAgentSession[] = []; for (const session of sortedSessions) { if (session.isArchived()) { @@ -912,19 +925,17 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou continue; } - const repoName = this.getRepositoryName(session); - if (!repoName) { - this.logService?.warn('[AgentSessions] Could not determine repository name for session, categorizing as "Other"', JSON.stringify(session)); + const repoName = getRepositoryName(session); + if (repoName) { + let group = repoMap.get(repoName); + if (!group) { + group = { label: repoName, sessions: [] }; + repoMap.set(repoName, group); + } + group.sessions.push(session); + } else { + otherSessions.push(session); } - const repoId = repoName || unknownKey; - const repoLabel = repoName || unknownLabel; - - let group = repoMap.get(repoId); - if (!group) { - group = { label: repoLabel, sessions: [] }; - repoMap.set(repoId, group); - } - group.sessions.push(session); } const result: AgentSessionListItem[] = []; @@ -945,6 +956,14 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou }); } + if (otherSessions.length > 0) { + result.push({ + section: AgentSessionSection.Repository, + label: AgentSessionSectionLabels[AgentSessionSection.Repository], + sessions: otherSessions, + }); + } + if (archivedSessions.length > 0) { result.push({ section: AgentSessionSection.Archived, @@ -955,10 +974,6 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou return result; } - - private getRepositoryName(session: IAgentSession): string | undefined { - return getRepositoryName(session); - } } /** @@ -969,6 +984,19 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou export function getRepositoryName(session: IAgentSession): string | undefined { const metadata = session.metadata; if (metadata) { + // Remote agent host sessions: group by folder + remote name (e.g. "myproject [dev-box]") + const remoteAgentHost = metadata.remoteAgentHost as string | undefined; + if (remoteAgentHost) { + const workingDir = metadata.workingDirectoryPath as string | undefined; + if (workingDir) { + const folderName = extractRepoNameFromPath(workingDir); + if (folderName) { + return `${folderName} [${remoteAgentHost}]`; + } + } + return remoteAgentHost; + } + // Cloud sessions: metadata.owner + metadata.name const owner = metadata.owner as string | undefined; const name = metadata.name as string | undefined; @@ -1112,12 +1140,13 @@ export const AgentSessionSectionLabels = { [AgentSessionSection.Older]: localize('agentSessions.olderSection', "Older"), [AgentSessionSection.Archived]: localize('agentSessions.archivedSection', "Archived"), [AgentSessionSection.More]: localize('agentSessions.moreSection', "More"), + [AgentSessionSection.Repository]: localize('agentSessions.noRepository', "Other"), }; const DAY_THRESHOLD = 24 * 60 * 60 * 1000; const WEEK_THRESHOLD = 7 * DAY_THRESHOLD; -export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map { +export function groupAgentSessionsByDate(sessions: IAgentSession[], sortBy?: AgentSessionsSorting): Map { const now = Date.now(); const startOfToday = new Date(now).setHours(0, 0, 0, 0); const startOfYesterday = startOfToday - DAY_THRESHOLD; @@ -1136,7 +1165,9 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -1217,6 +1248,12 @@ export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegat export class AgentSessionsSorter implements ITreeSorter { + private readonly getSortBy: () => AgentSessionsSorting; + + constructor(getSortBy?: () => AgentSessionsSorting) { + this.getSortBy = getSortBy ?? (() => AgentSessionsSorting.Created); + } + compare(sessionA: IAgentSession, sessionB: IAgentSession, prioritizeActiveSessions = false): number { // Special sorting if enabled @@ -1255,8 +1292,17 @@ export class AgentSessionsSorter implements ITreeSorter { } // Sort by time - const timeA = prioritizeActiveSessions ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created : sessionA.timing.created; - const timeB = prioritizeActiveSessions ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created : sessionB.timing.created; + const sortBy = this.getSortBy(); + const timeA = prioritizeActiveSessions + ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created + : sortBy === AgentSessionsSorting.Updated + ? sessionA.timing.lastRequestEnded ?? sessionA.timing.created + : sessionA.timing.created; + const timeB = prioritizeActiveSessions + ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created + : sortBy === AgentSessionsSorting.Updated + ? sessionB.timing.lastRequestEnded ?? sessionB.timing.created + : sessionB.timing.created; return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index a5e80a2aa79..e3855988f0a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -90,26 +90,6 @@ export class ExitAgentSessionProjectionAction extends Action2 { //#endregion -//#region Toggle Agent Status - -export class ToggleAgentStatusAction extends ToggleTitleBarConfigAction { - constructor() { - super( - ChatConfiguration.AgentStatusEnabled, - localize('toggle.agentStatus', 'Agent Status'), - localize('toggle.agentStatusDescription', "Toggle visibility of the Agent Status in title bar"), 6, - ContextKeyExpr.and( - ChatContextKeys.enabled, - IsCompactTitleBarContext.negate(), - ChatContextKeys.supported, - ContextKeyExpr.has('config.window.commandCenter') - ) - ); - } -} - -//#endregion - //#region Toggle Agent Quick Input export class ToggleUnifiedAgentsBarAction extends ToggleTitleBarConfigAction { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 07fec9b6d17..4e1d91d4927 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -6,7 +6,7 @@ import { registerSingleton, InstantiationType } from '../../../../../../platform/instantiation/common/extensions.js'; import { MenuId, MenuRegistry, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IAgentSessionProjectionService, AgentSessionProjectionService, AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS } from './agentSessionProjectionService.js'; -import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleAgentStatusAction, ToggleUnifiedAgentsBarAction } from './agentSessionProjectionActions.js'; +import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleUnifiedAgentsBarAction } from './agentSessionProjectionActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; import { AgentTitleBarStatusRendering } from './agentTitleBarStatusWidget.js'; import { AgentTitleBarStatusService, IAgentTitleBarStatusService } from './agentTitleBarStatusService.js'; @@ -235,7 +235,6 @@ class AgentSessionReadyContribution extends Disposable implements IWorkbenchCont registerAction2(EnterAgentSessionProjectionAction); registerAction2(ExitAgentSessionProjectionAction); -registerAction2(ToggleAgentStatusAction); registerAction2(ToggleUnifiedAgentsBarAction); registerSingleton(IAgentSessionProjectionService, AgentSessionProjectionService, InstantiationType.Delayed); @@ -251,10 +250,8 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { icon: Codicon.chatSparkle, when: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), - ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedAgentsBar}`) - ) + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, false) ), order: 10002 // to the right of the chat button }); @@ -271,7 +268,6 @@ MenuRegistry.appendMenuItem(MenuId.TitleBar, { ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabled.negate() ), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), ContextKeyExpr.has('config.window.commandCenter').negate(), ), order: 1 @@ -283,13 +279,7 @@ MenuRegistry.appendMenuItem(MenuId.AgentsTitleBarControlMenu, { id: 'workbench.action.chat.toggle', title: localize('openChat', "Open Chat"), }, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), - ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedAgentsBar}`) - ) - ), + when: ChatContextKeys.enabled, group: 'a_open', order: 1 }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 86ec453b950..0ec54a9d165 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -21,15 +21,10 @@ import { IAgentSessionsService } from '../agentSessionsService.js'; import { AgentSessionStatus, IAgentSession, isSessionInProgressStatus } from '../agentSessionsModel.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction, Separator, SubmenuAction, toAction } from '../../../../../../base/common/actions.js'; -import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; -import { IBrowserWorkbenchEnvironmentService } from '../../../../../services/environment/browser/environmentService.js'; import { IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { Verbosity } from '../../../../../common/editor.js'; -import { Schemas } from '../../../../../../base/common/network.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; -import { openSession } from '../agentSessionsOpener.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -43,6 +38,7 @@ import { IActionViewItemService } from '../../../../../../platform/actions/brows import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { LayoutSettings } from '../../../../../services/layout/browser/layoutService.js'; +import { WindowTitle } from '../../../../../browser/parts/titlebar/windowTitle.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetService } from '../../chat.js'; @@ -62,7 +58,7 @@ type AgentStatusClickAction = | 'exitProjection'; type AgentStatusClickEvent = { - source: 'pill' | 'sparkle' | 'unread' | 'inProgress'; + source: 'pill' | 'sparkle' | 'unread' | 'inProgress' | 'needsInput'; action: AgentStatusClickAction; }; @@ -84,8 +80,39 @@ const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfilt // Storage key for saving user's filter state before we override it const PREVIOUS_FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.previousUserFilter'; -const NLS_EXTENSION_HOST = localize('devExtensionWindowTitlePrefix', "[Extension Development Host]"); -const TITLE_DIRTY = '\u25cf '; +type AgentStatusSettingMode = 'hidden' | 'badge' | 'compact'; + +function shouldForceHiddenAgentStatus(configurationService: IConfigurationService): boolean { + const aiFeaturesDisabled = configurationService.getValue(ChatConfiguration.AIDisabled) === true; + const aiCustomizationsDisabled = configurationService.getValue('disableAICustomizations') === true + || configurationService.getValue('workbench.disableAICustomizations') === true + || configurationService.getValue(ChatConfiguration.ChatCustomizationMenuEnabled) === false; + + return aiFeaturesDisabled && aiCustomizationsDisabled; +} + +function getAgentStatusSettingMode(configurationService: IConfigurationService): AgentStatusSettingMode { + if (shouldForceHiddenAgentStatus(configurationService)) { + return 'hidden'; + } + + const value = configurationService.getValue(ChatConfiguration.AgentStatusEnabled); + + if (value === false || value === 'hidden') { + return 'hidden'; + } + + if (value === 'badge') { + return 'badge'; + } + + // Backward compatibility: previous experiments stored this as a boolean. + if (value === true || value === undefined || value === 'compact') { + return 'compact'; + } + + return 'compact'; +} /** * Agent Status Widget - renders agent status in the command center. @@ -102,7 +129,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { private readonly _dynamicDisposables = this._register(new DisposableStore()); /** The currently displayed in-progress session (if any) - clicking pill opens this */ - private _displayedSession: IAgentSession | undefined; /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; @@ -110,12 +136,13 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Guard to prevent re-entrant rendering */ private _isRendering = false; - /** First focusable element for keyboard navigation */ - private _firstFocusableElement: HTMLElement | undefined; + /** Roving tabindex elements for keyboard navigation */ + private _rovingElements: HTMLElement[] = []; + private _rovingIndex: number = 0; /** Tracks if this window applied a badge filter (unread/inProgress), so we only auto-clear our own filters */ // TODO: This is imperfect. Targetted fix for vscode#290863. We should revisit storing filter state per-window to avoid this - private _badgeFilterAppliedByThisWindow: 'unread' | 'inProgress' | null = null; + private _badgeFilterAppliedByThisWindow: 'unread' | 'inProgress' | 'needsInput' | null = null; /** Reusable menu for CommandCenterCenter items (e.g., debug toolbar) */ private readonly _commandCenterMenu; @@ -123,6 +150,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Menu for ChatTitleBarMenu items (same as chat controls dropdown) */ private readonly _chatTitleBarMenu; + /** WindowTitle instance for honoring the user's window.title setting */ + private readonly _windowTitle: WindowTitle; + constructor( action: IAction, options: IBaseActionViewItemOptions | undefined, @@ -132,9 +162,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { @ICommandService private readonly commandService: ICommandService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @ILabelService private readonly labelService: ILabelService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IBrowserWorkbenchEnvironmentService private readonly environmentService: IBrowserWorkbenchEnvironmentService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IEditorService private readonly editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, @@ -153,6 +181,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Create menu for ChatTitleBarMenu to show in sparkle section dropdown this._chatTitleBarMenu = this._register(this.menuService.createMenu(MenuId.ChatTitleBarMenu, this.contextKeyService)); + // Create WindowTitle to honor the user's window.title setting + this._windowTitle = this._register(this.instantiationService.createInstance(WindowTitle, mainWindow)); + // Re-render when control mode or session info changes this._register(this.agentTitleBarStatusService.onDidChangeMode(() => { this._render(); @@ -167,6 +198,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._render(); })); + // Re-render when window title changes (honors user's window.title setting) + this._register(this._windowTitle.onDidChange(() => { + this._render(); + })); + // Re-render when active editor changes (for file name display when tabs are hidden) this._register(this.editorService.onDidActiveEditorChange(() => { this._render(); @@ -192,7 +228,15 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Re-render when settings change this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled)) { + if ( + e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) + || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) + || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled) + || e.affectsConfiguration(ChatConfiguration.AIDisabled) + || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) + || e.affectsConfiguration('disableAICustomizations') + || e.affectsConfiguration('workbench.disableAICustomizations') + ) { this._lastRenderState = undefined; // Force re-render this._render(); } @@ -223,6 +267,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { super.render(container); this._container = container; container.classList.add('agent-status-container'); + container.setAttribute('role', 'toolbar'); + container.setAttribute('aria-label', localize('agentStatusToolbarLabel', "Agent Status")); // Container should not be focusable - inner elements handle focus container.tabIndex = -1; @@ -237,8 +283,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } override focus(): void { - // Focus the first focusable child instead - this._firstFocusableElement?.focus(); + this._rovingElements[this._rovingIndex]?.focus(); } override blur(): void { @@ -285,11 +330,10 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const label = this._getLabel(); // Get current filter state for state key - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); - // Check which settings are enabled (these are independent settings) + const statusMode = getAgentStatusSettingMode(this.configurationService); const unifiedAgentsBarEnabled = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; - const agentStatusEnabled = this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; // Build state key for comparison @@ -303,8 +347,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { label, isFilteredToUnread, isFilteredToInProgress, + isFilteredToNeedsInput, + statusMode, unifiedAgentsBarEnabled, - agentStatusEnabled, viewSessionsEnabled, }); @@ -317,9 +362,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Clear existing content reset(this._container); - // Clear previous disposables and focusable element for dynamic content + // Clear previous disposables and roving elements for dynamic content this._dynamicDisposables.clear(); - this._firstFocusableElement = undefined; + this._rovingElements = []; if (this.agentTitleBarStatusService.mode === AgentStatusMode.Session) { // Agent Session Projection mode - show session title + close button @@ -327,19 +372,77 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } else if (this.agentTitleBarStatusService.mode === AgentStatusMode.SessionReady) { // Session ready mode - show session title + enter projection button this._renderSessionReadyMode(this._dynamicDisposables); - } else if (unifiedAgentsBarEnabled) { - // Unified Agents Bar - show full pill with label + status badge + } else if (statusMode === 'compact') { + // Compact mode - replace command center search with integrated control this._renderChatInputMode(this._dynamicDisposables); - } else if (agentStatusEnabled) { - // Agent Status - show only the status badge (sparkle + unread/active counts) - this._renderBadgeOnlyMode(this._dynamicDisposables); + } else if (statusMode === 'badge') { + // Badge mode - render status badge next to command center search + this._renderStatusBadge(this._dynamicDisposables, activeSessions, unreadSessions, attentionNeededSessions); } - // If neither setting is enabled, nothing is rendered (container is already cleared) + // Hidden mode intentionally renders nothing. + + // Setup roving tabindex for keyboard navigation + this._setupRovingTabIndex(this._dynamicDisposables); } finally { this._isRendering = false; } } + /** + * Setup roving tabindex for arrow key navigation between interactive elements. + * Uses the elements registered in `this._rovingElements` in their existing order. + */ + private _setupRovingTabIndex(disposables: DisposableStore): void { + if (!this._container || this._rovingElements.length === 0) { + return; + } + + if (this._rovingIndex >= this._rovingElements.length) { + this._rovingIndex = 0; + } + for (let i = 0; i < this._rovingElements.length; i++) { + this._rovingElements[i].tabIndex = i === this._rovingIndex ? 0 : -1; + } + + disposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e) => { + const index = this._rovingElements.findIndex(el => el === e.target || el.contains(e.target as Node)); + if (index === -1) { + return; + } + + const nextIndex = this._getNextRovingIndex(index, e.key); + if (nextIndex !== undefined && nextIndex !== index) { + e.preventDefault(); + e.stopPropagation(); + this._moveRovingFocus(index, nextIndex); + } + })); + } + + /** + * Moves roving focus from `currentIndex` to `nextIndex`, updating tabIndex and focusing the element. + */ + private _moveRovingFocus(currentIndex: number, nextIndex: number): void { + this._rovingElements[currentIndex].tabIndex = -1; + this._rovingElements[nextIndex].tabIndex = 0; + this._rovingElements[nextIndex].focus(); + this._rovingIndex = nextIndex; + } + + /** + * Returns the next roving index for the given key, or `undefined` if no navigation should occur. + */ + private _getNextRovingIndex(currentIndex: number, key: string): number | undefined { + const len = this._rovingElements.length; + switch (key) { + case 'ArrowRight': return (currentIndex + 1) % len; + case 'ArrowLeft': return (currentIndex - 1 + len) % len; + case 'Home': return 0; + case 'End': return len - 1; + default: return undefined; + } + } + // #region Session Statistics /** @@ -392,20 +495,20 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const { activeSessions, unreadSessions, attentionNeededSessions, hasAttentionNeeded } = this._getSessionStats(); - // Render command center items (like debug toolbar) FIRST - to the left - this._renderCommandCenterToolbar(disposables); - // Create pill const pill = $('div.agent-status-pill.chat-input-mode'); if (hasAttentionNeeded) { pill.classList.add('needs-attention'); } - pill.setAttribute('role', 'button'); - pill.setAttribute('aria-label', localize('openQuickAccess', "Open Quick Access")); - pill.tabIndex = 0; - this._firstFocusableElement = pill; this._container.appendChild(pill); + // Render command center items (like debug toolbar) inside the pill + this._renderCommandCenterToolbar(disposables, pill); + + // Compact mode is always true when rendering chat input mode (caller already checked for compact) + const isCompactMode = true; + pill.classList.toggle('compact-mode', isCompactMode); + // Left icon container (sparkle by default, report+count when attention needed, search on hover) const leftIcon = $('span.agent-status-left-icon'); if (hasAttentionNeeded) { @@ -418,82 +521,105 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } else { reset(leftIcon, renderIcon(Codicon.searchSparkle)); } - pill.appendChild(leftIcon); + if (!isCompactMode) { + pill.appendChild(leftIcon); + } - // Label (workspace name by default, placeholder on hover) - // Show attention progress or default label + // Input area wrapper - hover only activates here, not on badge sections + const inputArea = $('div.agent-status-input-area'); + inputArea.setAttribute('role', 'button'); + inputArea.setAttribute('aria-label', localize('openQuickAccess', "Open Quick Access")); + inputArea.tabIndex = 0; + this._rovingElements.push(inputArea); + pill.appendChild(inputArea); + + // Label - always shows workspace name in compact mode const label = $('span.agent-status-label'); - const { session: attentionSession, progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); - this._displayedSession = attentionSession; + const { progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); + const defaultLabel = isCompactMode ? this._getLabel() : (progressText ?? this._getLabel()); - const defaultLabel = progressText ?? this._getLabel(); - - if (progressText) { + if (!isCompactMode && progressText) { label.classList.add('has-progress'); } const hoverLabel = localize('askAnythingPlaceholder', "Ask anything or describe what to build"); label.textContent = defaultLabel; - pill.appendChild(label); + inputArea.appendChild(label); - // Send icon (hidden by default, shown on hover - only when not showing attention message) - const sendIcon = $('span.agent-status-send'); - reset(sendIcon, renderIcon(Codicon.send)); - sendIcon.classList.add('hidden'); - pill.appendChild(sendIcon); - - // Hover behavior - swap icon and label (only when showing default state). - // When progressText is defined (e.g. sessions need attention), keep the attention/progress - // message visible and do not replace it with the generic placeholder on hover. - if (!progressText) { - disposables.add(addDisposableListener(pill, EventType.MOUSE_ENTER, () => { + if (isCompactMode) { + // Compact mode: hover resets icon state but keeps workspace name + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_ENTER, () => { reset(leftIcon, renderIcon(Codicon.searchSparkle)); leftIcon.classList.remove('has-attention'); - label.textContent = hoverLabel; label.classList.remove('has-progress'); - sendIcon.classList.remove('hidden'); })); - disposables.add(addDisposableListener(pill, EventType.MOUSE_LEAVE, () => { + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_LEAVE, () => { reset(leftIcon, renderIcon(Codicon.searchSparkle)); - label.textContent = defaultLabel; - sendIcon.classList.add('hidden'); })); + } else { + // Send icon (hidden by default, shown on hover - only when not showing attention message) + const sendIcon = $('span.agent-status-send'); + reset(sendIcon, renderIcon(Codicon.send)); + sendIcon.classList.add('hidden'); + inputArea.appendChild(sendIcon); + + // Hover behavior - swap icon and label (only when showing default state). + if (!progressText) { + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_ENTER, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + leftIcon.classList.remove('has-attention'); + label.textContent = hoverLabel; + label.classList.remove('has-progress'); + sendIcon.classList.remove('hidden'); + })); + + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_LEAVE, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + label.textContent = defaultLabel; + sendIcon.classList.add('hidden'); + })); + } } - // Setup hover tooltip + // Setup hover tooltip on input area const hoverDelegate = getDefaultHoverDelegate('mouse'); - disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, () => { - if (this._displayedSession) { - return localize('openSessionTooltip', "Open session: {0}", this._displayedSession.label); - } + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, inputArea, () => { const kbForTooltip = this.keybindingService.lookupKeybinding(UNIFIED_QUICK_ACCESS_ACTION_ID)?.getLabel(); return kbForTooltip ? localize('askTooltip', "Open Quick Access ({0})", kbForTooltip) : localize('askTooltip2', "Open Quick Access"); })); - // Click handler - open displayed session if showing progress, otherwise open unified quick access - disposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + // Click handler - always open quick access in compact mode (attention sessions are handled by the badge) + disposables.add(addDisposableListener(inputArea, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); - this._handlePillClick(); + this.telemetryService.publicLog2('agentStatusWidget.click', { + source: 'pill', + action: 'quickAccess', + }); + const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); })); // Keyboard handler - disposables.add(addDisposableListener(pill, EventType.KEY_DOWN, (e) => { + disposables.add(addDisposableListener(inputArea, EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this._handlePillClick(); + this.telemetryService.publicLog2('agentStatusWidget.click', { + source: 'pill', + action: 'quickAccess', + }); + const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); } })); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } + // In compact mode, render status badge inline within the pill + this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions, pill); } private _renderSessionMode(disposables: DisposableStore): void { @@ -537,10 +663,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.CLICK, exitHandler)); disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, exitHandler)); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } + // Status badge (separate rectangle on right) + this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } /** @@ -588,24 +712,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.CLICK, enterHandler)); disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, enterHandler)); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } - } - - /** - * Render badge-only mode - just the status badge without the full pill. - * Used when Agent Status is enabled but Enhanced Agent Status is not. - */ - private _renderBadgeOnlyMode(disposables: DisposableStore): void { - if (!this._container) { - return; - } - - const { activeSessions, unreadSessions, attentionNeededSessions } = this._getSessionStats(); - - // Status badge only - no pill, no command center toolbar + // Status badge (separate rectangle on right) this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } @@ -618,8 +725,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * Filters out the quick open action since we provide our own search UI. * Adds a dot separator after the toolbar if content was rendered. */ - private _renderCommandCenterToolbar(disposables: DisposableStore): void { - if (!this._container) { + private _renderCommandCenterToolbar(disposables: DisposableStore, parent?: HTMLElement): void { + const container = parent ?? this._container; + if (!container) { return; } @@ -647,7 +755,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const hoverDelegate = getDefaultHoverDelegate('mouse'); const toolbarContainer = $('div.agent-status-command-center-toolbar'); - this._container.appendChild(toolbarContainer); + container.appendChild(toolbarContainer); const toolbar = this.instantiationService.createInstance(WorkbenchToolBar, toolbarContainer, { hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -660,10 +768,17 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { toolbar.setActions(allActions); - // Add dot separator after the toolbar (matching command center style) - const separator = renderIcon(Codicon.circleSmallFilled); - separator.classList.add('agent-status-separator'); - this._container.appendChild(separator); + // Add separator after the toolbar + if (parent) { + // Inside pill (compact mode): use a vertical line separator + const separator = $('span.agent-status-line-separator'); + container.appendChild(separator); + } else { + // Outside pill: use dot separator (matching command center style) + const separator = renderIcon(Codicon.circleSmallFilled); + separator.classList.add('agent-status-separator'); + container.appendChild(separator); + } } /** @@ -680,9 +795,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { searchButton.setAttribute('role', 'button'); searchButton.setAttribute('aria-label', localize('openQuickOpen', "Open Quick Open")); searchButton.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = searchButton; - } + this._rovingElements.push(searchButton); container.appendChild(searchButton); // Setup hover @@ -715,7 +828,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * Shows split UI with sparkle icon on left, then unread, needs-input, and active indicators. * Always renders the sparkle icon section. */ - private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[], attentionNeededSessions: IAgentSession[]): void { + private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[], attentionNeededSessions: IAgentSession[], inlineContainer?: HTMLElement): void { if (!this._container) { return; } @@ -725,18 +838,21 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const hasAttentionNeeded = attentionNeededSessions.length > 0; // Auto-clear filter if the filtered category becomes empty if this window applied it - this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions); + this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions, hasAttentionNeeded); - const badge = $('div.agent-status-badge'); - this._container.appendChild(badge); + // When inlineContainer is provided, render sections directly into it (compact mode) + // Otherwise, create a separate badge container + let badge: HTMLElement; + if (inlineContainer) { + badge = inlineContainer; + } else { + badge = $('div.agent-status-badge'); + this._container.appendChild(badge); + } // Sparkle dropdown button section (always visible on left) - proper button with dropdown menu const sparkleContainer = $('span.agent-status-badge-section.sparkle'); sparkleContainer.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = sparkleContainer; - } - badge.appendChild(sparkleContainer); // Get menu actions for dropdown with proper group separators const menuActions: IAction[] = Separator.join(...this._chatTitleBarMenu.getActions({ shouldForwardArgs: true }).map(([, actions]) => actions)); @@ -792,6 +908,23 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { sparkleDropdown.render(sparkleContainer); disposables.add(sparkleDropdown); + // Capture-phase listener for ArrowLeft/ArrowRight/Home/End to prevent DropdownWithPrimaryActionViewItem + // from consuming these keys internally. This ensures the outer roving tabindex handles navigation. + disposables.add(addDisposableListener(sparkleContainer, EventType.KEY_DOWN, (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') { + const idx = this._rovingElements.indexOf(sparkleContainer); + if (idx === -1) { + return; + } + const nextIndex = this._getNextRovingIndex(idx, e.key); + if (nextIndex !== undefined && nextIndex !== idx) { + e.preventDefault(); + e.stopImmediatePropagation(); + this._moveRovingFocus(idx, nextIndex); + } + } + }, true /* useCapture */)); + // Add keyboard handler for Enter/Space on the sparkle container disposables.add(addDisposableListener(sparkleContainer, EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -812,10 +945,25 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Only show status indicators if chat.viewSessions.enabled is true const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; + // When compact mode is active, show status indicators before the sparkle button: + // [needs-input, active, unread, sparkle] (populating inward) + // Otherwise, keep original order: [sparkle, unread, active, needs-input] + const reverseOrder = !!inlineContainer; + + if (!reverseOrder) { + // Original order: sparkle first + badge.appendChild(sparkleContainer); + } + + // Build status sections but don't append yet - we need to control order + let unreadSection: HTMLElement | undefined; + let activeSection: HTMLElement | undefined; + let needsInputSection: HTMLElement | undefined; + // Unread section (blue dot + count) if (viewSessionsEnabled && hasUnreadSessions && this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY) { const { isFilteredToUnread } = this._getCurrentFilterState(); - const unreadSection = $('span.agent-status-badge-section.unread'); + unreadSection = $('span.agent-status-badge-section.unread'); if (isFilteredToUnread) { unreadSection.classList.add('filtered'); } @@ -827,7 +975,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const unreadCount = $('span.agent-status-text'); unreadCount.textContent = String(unreadSessions.length); unreadSection.appendChild(unreadCount); - badge.appendChild(unreadSection); // Click handler - filter to unread sessions disposables.add(addDisposableListener(unreadSection, EventType.CLICK, (e) => { @@ -850,30 +997,58 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(this.hoverService.setupManagedHover(hoverDelegate, unreadSection, unreadTooltip)); } - // In-progress/Needs-input section - shows "needs input" state when any session needs attention, - // otherwise shows "in progress" state. This is a single section that transforms based on state. - if (viewSessionsEnabled && hasActiveSessions) { - const { isFilteredToInProgress } = this._getCurrentFilterState(); - const activeSection = $('span.agent-status-badge-section.active'); - if (hasAttentionNeeded) { - activeSection.classList.add('needs-input'); + // Needs-input section - shows sessions requiring user attention (approval/confirmation/input) + if (viewSessionsEnabled && hasAttentionNeeded) { + const { isFilteredToNeedsInput } = this._getCurrentFilterState(); + needsInputSection = $('span.agent-status-badge-section.active.needs-input'); + if (isFilteredToNeedsInput) { + needsInputSection.classList.add('filtered'); } + needsInputSection.setAttribute('role', 'button'); + needsInputSection.tabIndex = 0; + const needsInputIcon = $('span.agent-status-icon'); + reset(needsInputIcon, renderIcon(Codicon.report)); + needsInputSection.appendChild(needsInputIcon); + const needsInputCount = $('span.agent-status-text'); + needsInputCount.textContent = String(attentionNeededSessions.length); + needsInputSection.appendChild(needsInputCount); + + disposables.add(addDisposableListener(needsInputSection, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('needsInput'); + })); + disposables.add(addDisposableListener(needsInputSection, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('needsInput'); + } + })); + + const needsInputTooltip = attentionNeededSessions.length === 1 + ? localize('needsInputSessionsTooltip1', "{0} session needs input", attentionNeededSessions.length) + : localize('needsInputSessionsTooltip', "{0} sessions need input", attentionNeededSessions.length); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, needsInputSection, needsInputTooltip)); + } + + // In-progress section - shows sessions that are actively running (excludes needs-input) + const inProgressOnly = activeSessions.filter(s => s.status !== AgentSessionStatus.NeedsInput); + if (viewSessionsEnabled && inProgressOnly.length > 0) { + const { isFilteredToInProgress } = this._getCurrentFilterState(); + activeSection = $('span.agent-status-badge-section.active'); if (isFilteredToInProgress) { activeSection.classList.add('filtered'); } activeSection.setAttribute('role', 'button'); activeSection.tabIndex = 0; const statusIcon = $('span.agent-status-icon'); - // Show report icon when needs input, otherwise session-in-progress icon - reset(statusIcon, renderIcon(hasAttentionNeeded ? Codicon.report : Codicon.sessionInProgress)); + reset(statusIcon, renderIcon(Codicon.sessionInProgress)); activeSection.appendChild(statusIcon); const statusCount = $('span.agent-status-text'); - // Show needs-input count when attention needed, otherwise total active count - statusCount.textContent = String(hasAttentionNeeded ? attentionNeededSessions.length : activeSessions.length); + statusCount.textContent = String(inProgressOnly.length); activeSection.appendChild(statusCount); - badge.appendChild(activeSection); - // Click handler - filter to in-progress sessions disposables.add(addDisposableListener(activeSection, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); @@ -887,17 +1062,28 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } })); - // Hover tooltip - different message based on state - const activeTooltip = hasAttentionNeeded - ? (attentionNeededSessions.length === 1 - ? localize('needsInputSessionsTooltip1', "{0} session needs input", attentionNeededSessions.length) - : localize('needsInputSessionsTooltip', "{0} sessions need input", attentionNeededSessions.length)) - : (activeSessions.length === 1 - ? localize('activeSessionsTooltip1', "{0} session in progress", activeSessions.length) - : localize('activeSessionsTooltip', "{0} sessions in progress", activeSessions.length)); + const activeTooltip = inProgressOnly.length === 1 + ? localize('activeSessionsTooltip1', "{0} session in progress", inProgressOnly.length) + : localize('activeSessionsTooltip', "{0} sessions in progress", inProgressOnly.length); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, activeSection, activeTooltip)); } + // Append status sections in the correct order and register for roving tabindex + if (reverseOrder) { + // [needs-input, active, unread, sparkle] — populates inward + if (needsInputSection) { badge.appendChild(needsInputSection); this._rovingElements.push(needsInputSection); } + if (activeSection) { badge.appendChild(activeSection); this._rovingElements.push(activeSection); } + if (unreadSection) { badge.appendChild(unreadSection); this._rovingElements.push(unreadSection); } + badge.appendChild(sparkleContainer); + this._rovingElements.push(sparkleContainer); + } else { + // Original: [sparkle (already appended), unread, active, needs-input] + this._rovingElements.push(sparkleContainer); + if (unreadSection) { badge.appendChild(unreadSection); this._rovingElements.push(unreadSection); } + if (activeSection) { badge.appendChild(activeSection); this._rovingElements.push(activeSection); } + if (needsInputSection) { badge.appendChild(needsInputSection); this._rovingElements.push(needsInputSection); } + } + } /** @@ -905,31 +1091,35 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * For example, if filtered to "unread" but no unread sessions exist, restore user's previous filter. * Only auto-clears if THIS window applied the badge filter to avoid cross-window interference. */ - private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean): void { + private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean, hasAttentionNeeded: boolean): void { // Only auto-clear if this window applied the badge filter // This prevents Window B from clearing filters that Window A set if (this._badgeFilterAppliedByThisWindow === 'unread' && !hasUnreadSessions) { this._restoreUserFilter(); } else if (this._badgeFilterAppliedByThisWindow === 'inProgress' && !hasActiveSessions) { this._restoreUserFilter(); + } else if (this._badgeFilterAppliedByThisWindow === 'needsInput' && !hasAttentionNeeded) { + this._restoreUserFilter(); } } /** * Get the current filter state from storage. */ - private _getCurrentFilterState(): { isFilteredToUnread: boolean; isFilteredToInProgress: boolean } { + private _getCurrentFilterState(): { isFilteredToUnread: boolean; isFilteredToInProgress: boolean; isFilteredToNeedsInput: boolean } { const filter = this._getStoredFilter(); if (!filter) { - return { isFilteredToUnread: false, isFilteredToInProgress: false }; + return { isFilteredToUnread: false, isFilteredToInProgress: false, isFilteredToNeedsInput: false }; } // Detect if filtered to unread (read=true excludes read sessions, leaving only unread) const isFilteredToUnread = filter.read === true && filter.states.length === 0; - // Detect if filtered to in-progress (2 excluded states = Completed + Failed) - const isFilteredToInProgress = filter.states?.length === 2 && filter.read === false; + // Detect if filtered to in-progress only (3 excluded states including NeedsInput) + const isFilteredToInProgress = filter.states?.length === 3 && filter.states.includes(AgentSessionStatus.NeedsInput) && filter.read === false; + // Detect if filtered to needs-input only (3 excluded states including InProgress) + const isFilteredToNeedsInput = filter.states?.length === 3 && filter.states.includes(AgentSessionStatus.InProgress) && filter.read === false; - return { isFilteredToUnread, isFilteredToInProgress }; + return { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput }; } /** @@ -972,11 +1162,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * This preserves the original user filter when switching between badge filters. */ private _saveUserFilter(): void { - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); // Don't overwrite the saved filter if we're already in a badge-filtered state // The previous user filter should already be saved - if (isFilteredToUnread || isFilteredToInProgress) { + if (isFilteredToUnread || isFilteredToInProgress || isFilteredToNeedsInput) { return; } @@ -1012,56 +1202,54 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** * Opens the agent sessions view with a specific filter applied, or restores previous filter if already applied. * Preserves session type (provider) filters while toggling only status filters. - * @param filterType 'unread' to show only unread sessions, 'inProgress' to show only in-progress sessions */ - private _openSessionsWithFilter(filterType: 'unread' | 'inProgress'): void { - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + private _openSessionsWithFilter(filterType: 'unread' | 'inProgress' | 'needsInput'): void { + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); const currentFilter = this._getStoredFilter(); // Preserve existing provider filters (session type filters like Local, Background, etc.) const preservedProviders = currentFilter?.providers ?? []; // Log telemetry for filter button clicks - const isToggleOff = (filterType === 'unread' && isFilteredToUnread) || (filterType === 'inProgress' && isFilteredToInProgress); + const isToggleOff = (filterType === 'unread' && isFilteredToUnread) + || (filterType === 'inProgress' && isFilteredToInProgress) + || (filterType === 'needsInput' && isFilteredToNeedsInput); this.telemetryService.publicLog2('agentStatusWidget.click', { source: filterType, action: isToggleOff ? 'clearFilter' : 'applyFilter', }); - // Toggle filter based on current state - if (filterType === 'unread') { - if (isFilteredToUnread) { - // Already filtered to unread - restore user's previous filter - this._restoreUserFilter(); - } else { - // Save current filter before applying our own - this._saveUserFilter(); - // Exclude read sessions to show only unread, preserving provider filters + // Check if already filtered to this type — toggle off + if (isToggleOff) { + this._restoreUserFilter(); + } else { + // Save current filter before applying our own + this._saveUserFilter(); + + if (filterType === 'unread') { this._storeFilter({ providers: preservedProviders, states: [], archived: true, read: true }); - // Track that this window applied the badge filter - this._badgeFilterAppliedByThisWindow = 'unread'; - } - } else { - if (isFilteredToInProgress) { - // Already filtered to in-progress - restore user's previous filter - this._restoreUserFilter(); - } else { - // Save current filter before applying our own - this._saveUserFilter(); - // Exclude Completed and Failed to show InProgress and NeedsInput, preserving provider filters + } else if (filterType === 'inProgress') { + // Exclude Completed, Failed, and NeedsInput — show only InProgress this._storeFilter({ providers: preservedProviders, - states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed], + states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed, AgentSessionStatus.NeedsInput], + archived: true, + read: false + }); + } else { + // Exclude Completed, Failed, and InProgress — show only NeedsInput + this._storeFilter({ + providers: preservedProviders, + states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed, AgentSessionStatus.InProgress], archived: true, read: false }); - // Track that this window applied the badge filter - this._badgeFilterAppliedByThisWindow = 'inProgress'; } + this._badgeFilterAppliedByThisWindow = filterType; } // Open the sessions view @@ -1077,6 +1265,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { escButton.setAttribute('role', 'button'); escButton.setAttribute('aria-label', localize('exitAgentSessionProjection', "Exit Agent Session Projection")); escButton.tabIndex = 0; + this._rovingElements.push(escButton); parent.appendChild(escButton); // Setup hover @@ -1117,9 +1306,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { enterButton.setAttribute('role', 'button'); enterButton.setAttribute('aria-label', localize('enterAgentSessionProjection', "Enter Agent Session Projection")); enterButton.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = enterButton; - } + this._rovingElements.push(enterButton); parent.appendChild(enterButton); // Setup hover @@ -1156,29 +1343,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // #endregion - // #region Click Handlers - - /** - * Handle pill click - opens the displayed session if showing progress, otherwise opens unified quick access - */ - private _handlePillClick(): void { - if (this._displayedSession) { - this.telemetryService.publicLog2('agentStatusWidget.click', { - source: 'pill', - action: 'openSession', - }); - this.instantiationService.invokeFunction(openSession, this._displayedSession); - } else { - this.telemetryService.publicLog2('agentStatusWidget.click', { - source: 'pill', - action: 'quickAccess', - }); - this.commandService.executeCommand(UNIFIED_QUICK_ACCESS_ACTION_ID); - } - } - - // #endregion - // #region Session Helpers /** @@ -1215,24 +1379,23 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // #region Label Helpers /** - * Compute the label to display, matching the command center behavior. - * Includes prefix and suffix decorations (remote host, extension dev host, etc.) + * Compute the label to display in the command center. + * Uses the workspace name (folder name) with prefix/suffix decorations. + * Falls back to file name when tabs are hidden, or "Search" when empty. */ private _getLabel(): string { - const { prefix, suffix } = this._getTitleDecorations(); + const { prefix, suffix } = this._windowTitle.getTitleDecorations(); - // Base label: workspace name or file name (when tabs are hidden) - let label = this.labelService.getWorkspaceLabel(this.workspaceContextService.getWorkspace()); - if (this.editorGroupsService.partOptions.showTabs === 'none') { - const activeEditor = this.editorService.activeEditor; - if (activeEditor) { - const dirty = activeEditor.isDirty() && !activeEditor.isSaving() ? TITLE_DIRTY : ''; - label = `${dirty}${activeEditor.getTitle(Verbosity.SHORT)}`; - } + // Base label: custom title, workspace name, or file name when tabs are hidden + let label = this._windowTitle.workspaceName; + if (this._windowTitle.isCustomTitleFormat()) { + label = this._windowTitle.getWindowTitle(); + } else if (!label && this.editorGroupsService.partOptions.showTabs === 'none') { + label = this._windowTitle.fileName ?? ''; } if (!label) { - label = localize('agentStatusWidget.askAnything', "Ask anything..."); + label = localize('agentStatusWidget.search', "Search"); } // Apply prefix and suffix decorations @@ -1246,28 +1409,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { return label.replaceAll(/\r\n|\r|\n/g, '\u23CE'); } - /** - * Get prefix and suffix decorations for the title (matching WindowTitle behavior) - */ - private _getTitleDecorations(): { prefix: string | undefined; suffix: string | undefined } { - let prefix: string | undefined; - const suffix: string | undefined = undefined; - - // Add remote host label if connected to a remote - if (this.environmentService.remoteAuthority) { - prefix = this.labelService.getHostLabel(Schemas.vscodeRemote, this.environmentService.remoteAuthority); - } - - // Add extension development host prefix - if (this.environmentService.isExtensionDevelopment) { - prefix = !prefix - ? NLS_EXTENSION_HOST - : `${NLS_EXTENSION_HOST} - ${prefix}`; - } - - return { prefix, suffix }; - } - // #endregion } @@ -1284,7 +1425,8 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, - @IConfigurationService configurationService: IConfigurationService + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService ) { super(); @@ -1295,19 +1437,38 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben return instantiationService.createInstance(AgentTitleBarStatusWidget, action, options); }, undefined)); - // Add/remove CSS classes on workbench based on settings - // Force enable command center and disable chat controls when agent status or unified agents bar is enabled + // Add/remove CSS classes on workbench based on settings. + // Only hide the default command center search box (via unified-agents-bar) + // when chat is enabled, so the search box remains visible during remote + // connection startup before the agent status widget is ready to render. + const chatEnabledKey = contextKeyService.getContextKeyValue('chatIsEnabled'); + let chatEnabled = !!chatEnabledKey; + const updateClass = () => { const commandCenterEnabled = configurationService.getValue(LayoutSettings.COMMAND_CENTER) === true; - const enabled = configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true && commandCenterEnabled; - const enhanced = configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true && commandCenterEnabled; + const statusMode = getAgentStatusSettingMode(configurationService); + const enabled = commandCenterEnabled && chatEnabled && statusMode !== 'hidden'; + const enhanced = enabled && statusMode === 'compact'; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); mainWindow.document.body.classList.toggle('unified-agents-bar', enhanced); }; updateClass(); this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER)) { + if ( + e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) + || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER) + || e.affectsConfiguration(ChatConfiguration.AIDisabled) + || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) + || e.affectsConfiguration('disableAICustomizations') + || e.affectsConfiguration('workbench.disableAICustomizations') + ) { + updateClass(); + } + })); + this._register(contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(new Set(['chatIsEnabled']))) { + chatEnabled = !!contextKeyService.getContextKeyValue('chatIsEnabled'); updateClass(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index 2da5d3bb06a..620950ec921 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -65,13 +65,24 @@ border-color: var(--vscode-commandCenter-activeBorder, transparent); } -.agent-status-pill.chat-input-mode { +/* Compact mode: pill hover is handled by individual sections, not the whole pill */ +.agent-status-pill.compact-mode { + padding: 0; + gap: 0; + background-color: transparent; +} + +.agent-status-pill.compact-mode:hover { + background-color: transparent; + border-color: var(--vscode-commandCenter-border, transparent); +} + +.agent-status-pill.chat-input-mode:not(.compact-mode) { cursor: pointer; } -.agent-status-pill.chat-input-mode:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; +.agent-status-pill.chat-input-mode .agent-status-input-area { + cursor: pointer; } .agent-status-pill.session-mode, @@ -95,13 +106,53 @@ color: var(--vscode-commandCenter-activeForeground); } -/* Label */ +/* Label - styled as placeholder text */ .agent-status-label { flex: 1; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + opacity: 0.6; +} + +/* Compact mode: left-aligned label, no icon */ +.agent-status-pill.compact-mode .agent-status-label { + text-align: left; +} + +/* Compact mode: inline status sections inside the pill */ +.agent-status-pill.compact-mode .agent-status-badge-section { + flex-shrink: 0; +} + +/* Input hover target - only this area triggers hover, not badge sections */ +.agent-status-pill .agent-status-input-area { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + overflow: hidden; + cursor: pointer; + gap: 6px; + border-radius: 5px 0 0 5px; + height: 100%; + padding: 0 10px; + background-color: var(--vscode-agentStatusIndicator-background); +} + +/* When preceded by a toolbar/separator, remove left border-radius */ +.agent-status-line-separator + .agent-status-input-area { + border-radius: 0; +} + +.agent-status-pill .agent-status-input-area:hover { + background-color: var(--vscode-commandCenter-activeBackground); +} + +.agent-status-pill .agent-status-input-area:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; } .agent-status-label.has-progress { @@ -203,6 +254,9 @@ display: flex; align-items: center; -webkit-app-region: no-drag; + height: 100%; + background-color: var(--vscode-agentStatusIndicator-background); + border-radius: 5px 0 0 5px; } .agent-status-separator { @@ -212,6 +266,16 @@ align-items: center; } +/* Vertical line separator used inside the pill in compact+debug mode */ +.agent-status-line-separator { + width: 1px; + align-self: stretch; + margin: 4px 0; + background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); + flex-shrink: 0; + pointer-events: none; +} + /* Status Badge */ .agent-status-badge { display: flex; @@ -235,6 +299,7 @@ height: 100%; position: relative; cursor: pointer; + background-color: var(--vscode-agentStatusIndicator-background); } .agent-status-badge-section:first-child { border-radius: 5px 0 0 5px; } @@ -281,7 +346,8 @@ } /* Separator between sections */ -.agent-status-badge-section + .agent-status-badge-section::before { +.agent-status-badge-section + .agent-status-badge-section::before, +.agent-status-input-area + .agent-status-badge-section::before { content: ''; position: absolute; left: 0; @@ -317,10 +383,16 @@ } .agent-status-badge-section.sparkle .action-container { - padding: 0 4px; + padding: 0 5px; border-radius: 5px 0 0 5px; } +/* In compact mode, no left radius on sparkle - it sits flush next to other sections */ +.agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { + border-radius: 0; + padding: 0 5px 0 6px; +} + .agent-status-badge-section.sparkle .dropdown-action-container { width: 18px; padding: 0; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index d72760c92a1..3e28c11d54f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -310,6 +310,7 @@ font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground); + text-transform: uppercase; /* align with session item padding */ padding: 0 6px; @@ -317,6 +318,11 @@ flex: 1; } + .agent-session-section-count { + opacity: 0.7; + margin-right: 4px; + } + .agent-session-section-toolbar { /* for the absolute positioning of the toolbar below */ position: relative; @@ -331,6 +337,11 @@ } } + .monaco-list-row:hover .agent-session-section .agent-session-section-count, + .monaco-list-row.focused:not(.selected) .agent-session-section .agent-session-section-count { + display: none; + } + .monaco-list-row:hover .agent-session-section .agent-session-section-toolbar, .monaco-list-row.focused:not(.selected) .agent-session-section .agent-session-section-toolbar { width: 22px; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 67186ad4470..014615bd6ed 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -10,7 +10,7 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl import { Emitter, Event } from '../../../../../base/common/event.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ResourceSet } from '../../../../../base/common/map.js'; @@ -19,10 +19,11 @@ import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; -import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY } from './aiCustomizationManagement.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { Delayer } from '../../../../../base/common/async.js'; @@ -40,6 +41,7 @@ import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../. import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; @@ -53,6 +55,10 @@ import { OS } from '../../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ICustomizationHarnessService, matchesWorkspaceSubpath, matchesInstructionFileFilter } from '../../common/customizationHarnessService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { getCleanPromptName, isInClaudeRulesFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; export { truncateToFirstSentence } from './aiCustomizationListWidgetUtils.js'; @@ -92,6 +98,16 @@ export interface IAICustomizationListItem { readonly disabled: boolean; /** When set, overrides `storage` for display grouping purposes. */ readonly groupKey?: string; + /** URI of the parent plugin, when this item comes from an installed plugin. */ + readonly pluginUri?: URI; + /** When set, overrides the formatted name for display. */ + readonly displayName?: string; + /** When set, shows a small inline badge next to the item name. */ + readonly badge?: string; + /** Tooltip shown when hovering the badge. */ + readonly badgeTooltip?: string; + /** When set, overrides the default prompt-type icon. */ + readonly typeIcon?: ThemeIcon; nameMatches?: IMatch[]; descriptionMatches?: IMatch[]; } @@ -143,6 +159,7 @@ interface IAICustomizationItemTemplateData { readonly actionBar: ActionBar; readonly typeIcon: HTMLElement; readonly nameLabel: HighlightedLabel; + readonly badge: HTMLElement; readonly description: HighlightedLabel; readonly disposables: DisposableStore; readonly elementDisposables: DisposableStore; @@ -235,6 +252,19 @@ function promptTypeToIcon(type: PromptsType): ThemeIcon { } } +/** + * Returns the icon for a given storage type. + */ +function storageToIcon(storage: PromptsStorage): ThemeIcon { + switch (storage) { + case PromptsStorage.local: return workspaceIcon; + case PromptsStorage.user: return userIcon; + case PromptsStorage.extension: return extensionIcon; + case PromptsStorage.plugin: return pluginIcon; + default: return instructionsIcon; + } +} + /** * Formats a name for display: strips a trailing .md extension, converts dashes/underscores * to spaces and applies title case. @@ -260,6 +290,7 @@ class AICustomizationItemRenderer implements IListRenderer { const uriLabel = this.labelService.getUriLabel(element.uri, { relative: false }); + let content = `${element.name}\n${uriLabel}`; + if (element.badgeTooltip) { + content += `\n\n${element.badgeTooltip}`; + } + const plugin = element.pluginUri && this.agentPluginService.plugins.get().find(p => isEqual(p.uri, element.pluginUri)); + if (plugin) { + content += `\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`; + } return { - content: `${element.name}\n${uriLabel}`, + content, appearance: { compact: true, skipFadeInAnimation: true, @@ -316,9 +358,25 @@ class AICustomizationItemRenderer implements IListRenderer): void { + for (const item of items) { + if (item.groupKey !== undefined) { + continue; // respect explicit groupKey from upstream (e.g. instruction categories) + } + if (item.storage !== PromptsStorage.extension) { + continue; + } + const extId = extensionIdByUri.get(item.uri.toString()); + const override = this.resolveExtensionGroupKey(extId); + if (override) { + // IAICustomizationListItem.groupKey is readonly for consumers but + // we own the items array here, so the mutation is safe. + (item as { groupKey?: string }).groupKey = override; + } + } + } + /** * Fetches and filters items for a given section. * Shared between `loadItems` (active section) and `computeItemCountForSection` (any section). @@ -989,6 +1114,7 @@ export class AICustomizationListWidget extends Disposable { const promptType = sectionToPromptType(section); const items: IAICustomizationListItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const extensionIdByUri = new Map(); if (promptType === PromptsType.agent) { @@ -1004,12 +1130,24 @@ export class AICustomizationListWidget extends Disposable { description: agent.description, storage: agent.source.storage, promptType, + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, disabled: disabledUris.has(agent.uri), }); + // Track extension ID for built-in grouping + if (agent.source.storage === PromptsStorage.extension) { + extensionIdByUri.set(agent.uri.toString(), agent.source.extensionId); + } } } else if (promptType === PromptsType.skill) { // Use findAgentSkills for enabled skills (has parsed name/description from frontmatter) const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + // Build extension ID lookup from raw file list (like MCP builds collectionSources) + const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); + for (const file of allSkillFiles) { + if (file.extension) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + } + } const seenUris = new ResourceSet(); for (const skill of skills || []) { const filename = basename(skill.uri); @@ -1023,12 +1161,12 @@ export class AICustomizationListWidget extends Disposable { description: skill.description, storage: skill.storage, promptType, + pluginUri: skill.storage === PromptsStorage.plugin ? this.findPluginUri(skill.uri) : undefined, disabled: false, }); } // Also include disabled skills from the raw file list if (disabledUris.size > 0) { - const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); for (const file of allSkillFiles) { if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { const filename = basename(file.uri); @@ -1062,8 +1200,12 @@ export class AICustomizationListWidget extends Disposable { description: command.description, storage: command.promptPath.storage, promptType, + pluginUri: command.promptPath.storage === PromptsStorage.plugin ? command.promptPath.pluginUri : undefined, disabled: disabledUris.has(command.promptPath.uri), }); + if (command.promptPath.extension) { + extensionIdByUri.set(command.promptPath.uri.toString(), command.promptPath.extension.identifier); + } } } else if (promptType === PromptsType.hook) { // Try to parse individual hooks from each file; fall back to showing the file itself @@ -1073,6 +1215,23 @@ export class AICustomizationListWidget extends Disposable { const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; for (const hookFile of hookFiles) { + // Plugins parse their own hooks and emit them individually because they can + // be embedded with interpolations in the plugin manifests; don't re-parse them + if (hookFile.storage === PromptsStorage.plugin) { + const filename = basename(hookFile.uri); + items.push({ + id: hookFile.uri.toString() + ':' + hookFile.name, + uri: hookFile.uri, + name: hookFile.name || this.getFriendlyName(filename), + filename, + storage: hookFile.storage, + promptType, + pluginUri: hookFile.pluginUri, + disabled: disabledUris.has(hookFile.uri), + }); + continue; + } + let parsedHooks = false; try { const content = await this.fileService.readFile(hookFile.uri); @@ -1109,7 +1268,7 @@ export class AICustomizationListWidget extends Disposable { items.push({ id: hookFile.uri.toString(), uri: hookFile.uri, - name: this.getFriendlyName(filename), + name: hookFile.name || this.getFriendlyName(filename), filename, storage: hookFile.storage, promptType, @@ -1144,64 +1303,115 @@ export class AICustomizationListWidget extends Disposable { storage: agent.source.storage, groupKey: 'agents', promptType, + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, disabled: disabledUris.has(agent.uri), }); } } } } else { - // For instructions, fetch prompt files and group by storage + // For instructions, group by category: agent instructions, context instructions, on-demand instructions const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); - const allItems: IPromptPath[] = [...promptFiles]; - - // Also include agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) - if (promptType === PromptsType.instructions) { - const agentInstructions = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); - const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - if (activeRoot) { - workspaceFolderUris.push(activeRoot); + for (const file of promptFiles) { + if (file.extension) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); } - for (const file of agentInstructions) { - const isWorkspaceFile = workspaceFolderUris.some(root => isEqualOrParent(file.uri, root)); - allItems.push({ - uri: file.uri, - storage: isWorkspaceFile ? PromptsStorage.local : PromptsStorage.user, - type: PromptsType.instructions, - name: basename(file.uri), + } + const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); + const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); + + // Add agent instruction items + const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); + const activeRoot = this.workspaceService.getActiveProjectRoot(); + if (activeRoot) { + workspaceFolderUris.push(activeRoot); + } + for (const file of agentInstructionFiles) { + const storage = PromptsStorage.local; + const filename = basename(file.uri); + items.push({ + id: file.uri.toString(), + uri: file.uri, + name: filename, + filename: this.labelService.getUriLabel(file.uri, { relative: true }), + displayName: filename, + storage, + promptType, + typeIcon: storageToIcon(storage), + groupKey: 'agent-instructions', + disabled: disabledUris.has(file.uri), + }); + } + + // Parse prompt files to separate into context vs on-demand + const promptFilesToParse = promptFiles.filter(item => !agentInstructionUris.has(item.uri)); + const parseResults = await Promise.all(promptFilesToParse.map(async item => { + try { + const parsed = await this.promptsService.parseNew(item.uri, CancellationToken.None); + return { item, parsed }; + } catch { + // Parse failed — treat as on-demand + return { item, parsed: undefined }; + } + })); + + for (const { item, parsed } of parseResults) { + const applyTo = evaluateApplyToPattern(parsed?.header, isInClaudeRulesFolder(item.uri)); + const name = parsed?.header?.name; + let description = parsed?.header?.description; + const friendlyName = this.getFriendlyName(name || item.name || getCleanPromptName(item.uri)); + description = description || item.description; + + if (applyTo !== undefined) { + // Context instruction + const badge = applyTo === '**' + ? localize('alwaysAdded', "always added") + : applyTo; + const badgeTooltip = applyTo === '**' + ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") + : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", applyTo); + items.push({ + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename: this.labelService.getUriLabel(item.uri, { relative: true }), + displayName: friendlyName, + badge, + badgeTooltip, + description: description, + storage: item.storage, + promptType, + typeIcon: storageToIcon(item.storage), + groupKey: 'context-instructions', + pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, + disabled: disabledUris.has(item.uri), + }); + } else { + // On-demand instruction + items.push({ + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename: basename(item.uri), + displayName: friendlyName, + description: description, + storage: item.storage, + promptType, + typeIcon: storageToIcon(item.storage), + groupKey: 'on-demand-instructions', + pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, + disabled: disabledUris.has(item.uri), }); } } - - const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local); - const userItems = allItems.filter(item => item.storage === PromptsStorage.user); - const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); - const pluginItems = allItems.filter(item => item.storage === PromptsStorage.plugin); - const builtinItems = allItems.filter(item => item.storage === BUILTIN_STORAGE); - - const mapToListItem = (item: IPromptPath): IAICustomizationListItem => { - const filename = basename(item.uri); - // For instructions, derive a friendly name from filename - const friendlyName = item.name || this.getFriendlyName(filename); - return { - id: item.uri.toString(), - uri: item.uri, - name: friendlyName, - filename, - description: item.description, - storage: item.storage, - promptType, - disabled: disabledUris.has(item.uri), - }; - }; - - items.push(...workspaceItems.map(mapToListItem)); - items.push(...userItems.map(mapToListItem)); - items.push(...extensionItems.map(mapToListItem)); - items.push(...pluginItems.map(mapToListItem)); - items.push(...builtinItems.map(mapToListItem)); } + // Assign built-in groupKeys — items from the default chat extension + // are re-grouped under "Built-in" instead of "Extensions". + // This is a single-pass transformation applied after all items are + // collected, keeping the item-building code free of grouping logic. + this.applyBuiltinGroupKeys(items, extensionIdByUri); + // Apply storage source filter (removes items not in visible sources or excluded user roots) const filter = this.workspaceService.getStorageSourceFilter(promptType); const filteredItems = applyStorageSourceFilter(items, filter); @@ -1284,12 +1494,13 @@ export class AICustomizationListWidget extends Disposable { for (const item of this.allItems) { // Compute matches against the formatted display name so highlight positions // are correct even after .md stripping and title-casing. - const displayName = formatDisplayName(item.name); + const displayName = item.displayName ?? formatDisplayName(item.name); const nameMatches = matchesContiguousSubString(query, displayName); const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; const filenameMatches = matchesContiguousSubString(query, item.filename); + const badgeMatches = item.badge ? matchesContiguousSubString(query, item.badge) : null; - if (nameMatches || descriptionMatches || filenameMatches) { + if (nameMatches || descriptionMatches || filenameMatches || badgeMatches) { matchedItems.push({ ...item, nameMatches: nameMatches || undefined, @@ -1299,17 +1510,24 @@ export class AICustomizationListWidget extends Disposable { } } - // Group items by storage + // Group items — instructions use category-based grouping; other sections use storage-based const promptType = sectionToPromptType(this.currentSection); const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); - const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ - { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, - { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, - ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = + this.currentSection === AICustomizationManagementSection.Instructions + ? [ + { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, + { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, + { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, + ] + : [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, + { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, + ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); for (const item of matchedItems) { const key = item.groupKey ?? item.storage; @@ -1414,6 +1632,18 @@ export class AICustomizationListWidget extends Disposable { } } + /** + * Finds the plugin URI for an item URI by checking the known plugins. + */ + private findPluginUri(itemUri: URI): URI | undefined { + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(itemUri, plugin.uri)) { + return plugin.uri; + } + } + return undefined; + } + private getEmptyStateInfo(): { title: string; description: string } { switch (this.currentSection) { case AICustomizationManagementSection.Agents: diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index f1e52277bc0..ac7d63aba97 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -3,51 +3,52 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { isMacintosh, isWindows } from '../../../../../base/common/platform.js'; +import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { IEditorPaneRegistry, EditorPaneDescriptor } from '../../../../browser/editor.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../browser/editor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; -import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; -import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { AgentPluginItemKind } from '../agentPluginEditor/agentPluginItems.js'; import { - AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, - AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, AICustomizationManagementCommands, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, } from './aiCustomizationManagement.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { PromptsStorage, IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ChatConfiguration } from '../../common/constants.js'; -import { IFileService, FileSystemProviderCapabilities } from '../../../../../platform/files/common/files.js'; -import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { isWindows, isMacintosh } from '../../../../../base/common/platform.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; -import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; -import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; //#region Telemetry @@ -155,6 +156,20 @@ function extractPromptType(context: AICustomizationContext): PromptsType | undef return context.promptType; } +/** + * Extracts the parent plugin URI from context, if present. + */ +function extractPluginUri(context: AICustomizationContext): URI | undefined { + if (URI.isUri(context) || typeof context === 'string') { + return undefined; + } + const raw = context.pluginUri; + if (!raw) { + return undefined; + } + return URI.isUri(raw) ? raw : typeof raw === 'string' ? URI.parse(raw) : undefined; +} + // Open file action const OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID = 'aiCustomizationManagement.openFile'; registerAction2(class extends Action2 { @@ -437,6 +452,52 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: WHEN_ITEM_IS_PLUGIN, }); +// Show Plugin action - navigates to the parent plugin detail page +const SHOW_PLUGIN_AI_CUSTOMIZATION_ID = 'aiCustomizationManagement.showPlugin'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: SHOW_PLUGIN_AI_CUSTOMIZATION_ID, + title: localize2('showPlugin', "Show Plugin"), + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const agentPluginService = accessor.get(IAgentPluginService); + const editorService = accessor.get(IEditorService); + + const pluginUri = extractPluginUri(context); + if (!pluginUri) { + return; + } + const plugin = agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUri.toString()); + if (!plugin) { + return; + } + + const item = { + kind: AgentPluginItemKind.Installed as const, + name: plugin.label, + description: plugin.fromMarketplace?.description ?? '', + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + + // Try to show within the active AI Customization editor (with back navigation) + const input = AICustomizationManagementEditorInput.getOrCreate(); + const pane = await editorService.openEditor(input, { pinned: true }); + if (pane instanceof AICustomizationManagementEditor) { + await pane.showPluginDetail(item); + } + } +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: SHOW_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('showPlugin', "Show Plugin") }, + group: '1_open', + order: 2, + when: WHEN_ITEM_IS_PLUGIN, +}); + // Disable item action const DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID = 'aiCustomizationManagement.disableItem'; registerAction2(class extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 008c653a8b0..d6439837a8e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -5,23 +5,13 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; // Re-export for convenience — consumers import from this file export { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; - -/** - * Extended storage type for AI Customization that includes built-in prompts - * shipped with the application, alongside the core `PromptsStorage` values. - */ -export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; - -/** - * Storage type discriminator for built-in prompts shipped with the application. - */ -export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; +export type { AICustomizationPromptsStorage } from '../../common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; /** * Editor pane ID for the AI Customizations Management Editor. @@ -87,6 +77,11 @@ export const AI_CUSTOMIZATION_ITEM_STORAGE_KEY = 'aiCustomizationManagementItemS */ export const AI_CUSTOMIZATION_ITEM_URI_KEY = 'aiCustomizationManagementItemUri'; +/** + * Context key for the parent plugin URI, set when the item is provided by a plugin. + */ +export const AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY = 'aiCustomizationManagementItemPluginUri'; + /** * Context key indicating whether the item is disabled. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 55b98a33a88..2896cbbef08 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -74,6 +74,8 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { Action } from '../../../../../base/common/actions.js'; import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -287,6 +289,8 @@ export class AICustomizationManagementEditor extends EditorPane { private pluginDetailContainer: HTMLElement | undefined; private embeddedPluginEditor: AgentPluginEditor | undefined; private readonly pluginDetailDisposables = this._register(new DisposableStore()); + /** Section to restore when navigating back from plugin detail (when opened from a non-plugin section). */ + private pluginDetailReturnSection: AICustomizationManagementSection | undefined; private dimension: DOM.Dimension | undefined; private readonly sections: ISectionItem[] = []; @@ -329,6 +333,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IHoverService private readonly hoverService: IHoverService, @IModelService private readonly modelService: IModelService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, @IFileService private readonly fileService: IFileService, @INotificationService private readonly notificationService: INotificationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @@ -604,7 +609,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateHarnessDropdown(); this.editorDisposables.add(DOM.addDisposableListener(this.harnessDropdownButton, 'click', () => { - this.showHarnessPicker(); + this.showHarnessMenu(); })); } @@ -625,31 +630,26 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private showHarnessPicker(): void { + private showHarnessMenu(): void { + if (!this.harnessDropdownButton) { + return; + } const harnesses = this.harnessService.availableHarnesses.get(); const activeId = this.harnessService.activeHarness.get(); - const items = harnesses.map(h => ({ - label: h.label, - iconClass: ThemeIcon.asClassName(h.icon), - id: h.id, - picked: h.id === activeId, - })); - - const picker = this.quickInputService.createQuickPick(); - picker.items = items; - picker.placeholder = localize('selectTarget', "Select customization target"); - picker.canSelectMany = false; - picker.activeItems = items.filter(i => i.picked); - picker.onDidAccept(() => { - const selected = picker.activeItems[0] as typeof items[0] | undefined; - if (selected) { - this.harnessService.setActiveHarness(selected.id); - } - picker.dispose(); + const actions = harnesses.map(h => { + const action = new Action(h.id, h.label, ThemeIcon.asClassName(h.icon), true, () => { + this.harnessService.setActiveHarness(h.id); + }); + action.checked = h.id === activeId; + return action; + }); + + this.contextMenuService.showContextMenu({ + getAnchor: () => this.harnessDropdownButton!, + getActions: () => actions, + getCheckedActionsRepresentation: () => 'radio', }); - picker.onDidHide(() => picker.dispose()); - picker.show(); } private createFolderPicker(sidebarContent: HTMLElement): void { @@ -775,6 +775,10 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorDisposables.add(this.mcpListWidget.onDidSelectServer(server => { this.showEmbeddedMcpDetail(server); })); + + this.editorDisposables.add(this.mcpListWidget.onDidRequestShowPlugin(item => { + this.showPluginDetail(item); + })); } // Container for Plugins content @@ -788,6 +792,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.createEmbeddedPluginDetail(); this.editorDisposables.add(this.pluginListWidget.onDidSelectPlugin(item => { + this.pluginDetailReturnSection = undefined; this.showEmbeddedPluginDetail(item); })); } @@ -1794,16 +1799,40 @@ export class AICustomizationManagementEditor extends EditorPane { } } + /** + * Public method to show a plugin detail from any section (e.g. from "Show Plugin" context menu). + * Saves the current section so the back button returns the user to it. + */ + public async showPluginDetail(item: IAgentPluginItem): Promise { + if (this.selectedSection !== AICustomizationManagementSection.Plugins) { + this.pluginDetailReturnSection = this.selectedSection; + } + await this.showEmbeddedPluginDetail(item); + } + private goBackFromPluginDetail(): void { this.pluginDetailDisposables.clear(); this.embeddedPluginEditor?.clearInput(); - this.viewMode = 'list'; - this.updateContentVisibility(); + + const returnSection = this.pluginDetailReturnSection; + this.pluginDetailReturnSection = undefined; + + if (returnSection) { + // Return to the section the user was on before opening the plugin detail. + // selectSection may early-return when the section hasn't changed, so always + // ensure viewMode and content visibility are updated. + this.viewMode = 'list'; + this.updateContentVisibility(); + this.selectSection(returnSection); + } else { + this.viewMode = 'list'; + this.updateContentVisibility(); + this.pluginListWidget?.focusSearch(); + } if (this.dimension) { this.layout(this.dimension); } - this.pluginListWidget?.focusSearch(); } //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index f87b510db74..3f03253741d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -17,6 +17,7 @@ import { getClaudeUserRoots, } from '../../common/customizationHarnessService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IChatAgentService } from '../../common/participants/chatAgents.js'; @@ -31,9 +32,10 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { @IChatAgentService chatAgentService: IChatAgentService, ) { const userHome = pathService.userHome({ preferLocal: true }); - // Only the Local harness includes extension-contributed customizations. + // The Local harness includes extension-contributed and built-in customizations. + // Built-in items come from the default chat extension (productService.defaultChatAgent). // CLI and Claude harnesses don't consume extension contributions. - const localExtras = [PromptsStorage.extension]; + const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; const restrictedExtras: readonly string[] = []; const allHarnesses: readonly IHarnessDescriptor[] = [ createVSCodeHarnessDescriptor(localExtras), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 4565a981675..f809954da8a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -16,7 +16,9 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService, IMcpServer } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { isContributionDisabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; import { autorun } from '../../../../../base/common/observable.js'; @@ -31,23 +33,36 @@ import { getContextMenuActions } from '../../../../contrib/mcp/browser/mcpServer import { LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { workspaceIcon, userIcon, mcpServerIcon, builtinIcon, extensionIcon } from './aiCustomizationIcons.js'; +import { workspaceIcon, userIcon, mcpServerIcon, builtinIcon, pluginIcon, extensionIcon } from './aiCustomizationIcons.js'; import { formatDisplayName, truncateToFirstSentence } from './aiCustomizationListWidget.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, CustomizationHarness } from '../../common/customizationHarnessService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; +import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; const $ = DOM.$; const MCP_ITEM_HEIGHT = 36; +const PLUGIN_COLLECTION_PREFIX = 'plugin.'; + +const COPILOT_EXTENSION_IDS = ['github.copilot', 'github.copilot-chat']; + +function isCopilotExtension(id: ExtensionIdentifier): boolean { + return COPILOT_EXTENSION_IDS.some(copilotId => ExtensionIdentifier.equals(id, copilotId)); +} + +function getPluginUriFromCollectionId(collectionId: string | undefined): string | undefined { + return collectionId?.startsWith(PLUGIN_COLLECTION_PREFIX) ? collectionId.slice(PLUGIN_COLLECTION_PREFIX.length) : undefined; +} + /** * Represents a collapsible group header in the MCP server list. */ interface IMcpGroupHeaderEntry extends ICustomizationGroupHeaderEntry { - readonly scope: LocalMcpServerScope | 'builtin' | 'extension'; + readonly scope: LocalMcpServerScope | 'builtin' | 'plugin' | 'extension'; } /** @@ -116,6 +131,7 @@ class McpServerItemRenderer implements IListRenderer { + const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); + if (plugin) { + return { + content: `${element.label}\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`, + appearance: { compact: true, skipFadeInAnimation: true }, + }; + } + return { content: element.label, appearance: { compact: true, skipFadeInAnimation: true } }; + })); + } return; } @@ -328,6 +359,9 @@ export class McpListWidget extends Disposable { private readonly _onDidChangeItemCount = this._register(new Emitter()); readonly onDidChangeItemCount = this._onDidChangeItemCount.event; + private readonly _onDidRequestShowPlugin = this._register(new Emitter()); + readonly onDidRequestShowPlugin = this._onDidRequestShowPlugin.event; + private sectionHeader!: HTMLElement; private sectionDescription!: HTMLElement; private sectionLink!: HTMLAnchorElement; @@ -357,6 +391,7 @@ export class McpListWidget extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, @IMcpService private readonly mcpService: IMcpService, + @IMcpRegistry private readonly mcpRegistry: IMcpRegistry, @ICommandService private readonly commandService: ICommandService, @IOpenerService private readonly openerService: IOpenerService, @IContextViewService private readonly contextViewService: IContextViewService, @@ -708,64 +743,103 @@ export class McpListWidget extends Disposable { isFirst = false; } - // Add extension-provided and built-in servers - if (builtinServers.length > 0) { - const extensionServers = builtinServers.filter(s => s.collection.id.startsWith('ext.')); - const otherBuiltinServers = builtinServers.filter(s => !s.collection.id.startsWith('ext.')); - - if (extensionServers.length > 0) { - const collapsed = this.collapsedGroups.has('extension'); - entries.push({ - type: 'group-header', - id: 'mcp-group-extension', - scope: 'extension', - label: localize('extensionGroup', "Extensions"), - icon: extensionIcon, - count: extensionServers.length, - isFirst, - description: localize('extensionGroupDescription', "MCP servers contributed by installed VS Code extensions."), - collapsed, - }); - if (!collapsed) { - for (const server of extensionServers) { - entries.push({ - type: 'builtin-item', - id: `builtin-${server.definition.id}`, - label: server.definition.label, - description: '', - collectionId: server.collection.id, - }); - } - } - isFirst = false; + // Add plugin-provided, extension-provided, and built-in servers. + // Servers from the Copilot extension (github.copilot / github.copilot-chat) + // are treated as built-in; servers from other extensions go under "Extensions". + const collectionSources = new Map(this.mcpRegistry.collections.get().map(c => [c.id, c.source])); + const pluginServers: IMcpServer[] = []; + const extensionServers: IMcpServer[] = []; + const otherBuiltinServers: IMcpServer[] = []; + for (const server of builtinServers) { + const source = collectionSources.get(server.collection.id); + if (server.collection.id.startsWith(PLUGIN_COLLECTION_PREFIX)) { + pluginServers.push(server); + } else if (source instanceof ExtensionIdentifier && !isCopilotExtension(source)) { + extensionServers.push(server); + } else { + otherBuiltinServers.push(server); } + } - if (otherBuiltinServers.length > 0) { - const collapsed = this.collapsedGroups.has('builtin'); - entries.push({ - type: 'group-header', - id: 'mcp-group-builtin', - scope: 'builtin', - label: localize('builtInGroup', "Built-in"), - icon: builtinIcon, - count: otherBuiltinServers.length, - isFirst, - description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), - collapsed, - }); - if (!collapsed) { - for (const server of otherBuiltinServers) { - entries.push({ - type: 'builtin-item', - id: `builtin-${server.definition.id}`, - label: server.definition.label, - description: '', - collectionId: server.collection.id, - }); - } + if (pluginServers.length > 0) { + const collapsed = this.collapsedGroups.has('plugin'); + entries.push({ + type: 'group-header', + id: 'mcp-group-plugin', + scope: 'plugin', + label: localize('pluginGroup', "Plugins"), + icon: pluginIcon, + count: pluginServers.length, + isFirst, + description: localize('pluginGroupDescription', "MCP servers provided by installed plugins."), + collapsed, + }); + if (!collapsed) { + for (const server of pluginServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); } - isFirst = false; } + isFirst = false; + } + + if (extensionServers.length > 0) { + const collapsed = this.collapsedGroups.has('extension'); + entries.push({ + type: 'group-header', + id: 'mcp-group-extension', + scope: 'extension', + label: localize('extensionGroup', "Extensions"), + icon: extensionIcon, + count: extensionServers.length, + isFirst, + description: localize('extensionGroupDescription', "MCP servers contributed by installed VS Code extensions."), + collapsed, + }); + if (!collapsed) { + for (const server of extensionServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); + } + } + isFirst = false; + } + + if (otherBuiltinServers.length > 0) { + const collapsed = this.collapsedGroups.has('builtin'); + entries.push({ + type: 'group-header', + id: 'mcp-group-builtin', + scope: 'builtin', + label: localize('builtInGroup', "Built-in"), + icon: builtinIcon, + count: otherBuiltinServers.length, + isFirst, + description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), + collapsed, + }); + if (!collapsed) { + for (const server of otherBuiltinServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); + } + } + isFirst = false; } this.displayEntries = entries; @@ -861,16 +935,32 @@ export class McpListWidget extends Disposable { // Plugin-provided builtin items get an "Uninstall Plugin" context menu if (e.element.type === 'builtin-item') { const collectionId = e.element.collectionId; - if (!collectionId?.startsWith('plugin.')) { + const pluginUriStr = getPluginUriFromCollectionId(collectionId); + if (!pluginUriStr) { return; } - const pluginUriStr = collectionId.slice('plugin.'.length); const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); if (!plugin) { return; } const disposables = new DisposableStore(); + const showPluginAction = disposables.add(new Action( + 'mcpServer.showPlugin', + localize('showPlugin', "Show Plugin"), + undefined, + true, + async () => { + const item = { + kind: AgentPluginItemKind.Installed as const, + name: plugin.label, + description: plugin.fromMarketplace?.description ?? '', + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + this._onDidRequestShowPlugin.fire(item); + } + )); const uninstallAction = disposables.add(new Action( 'mcpServer.uninstallPlugin', localize('uninstallPlugin', "Uninstall Plugin"), @@ -891,7 +981,7 @@ export class McpListWidget extends Disposable { this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, - getActions: () => [uninstallAction], + getActions: () => [showPluginAction, uninstallAction], onHide: () => disposables.dispose(), }); return; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 00b2ab561ef..b4e17ae435e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -452,14 +452,8 @@ opacity: 0.6; } -/* MCP bridged badge — shown inline next to the server name */ -.mcp-server-item .mcp-server-name-row { - display: flex; - align-items: center; - gap: 6px; -} - -.mcp-server-item .mcp-bridged-badge { +/* Shared inline badge style — used by MCP "Bridged" badge and item badges */ +.inline-badge { flex-shrink: 0; font-size: 10px; padding: 0 4px; @@ -470,6 +464,13 @@ line-height: 16px; } +/* MCP bridged badge — shown inline next to the server name */ +.mcp-server-item .mcp-server-name-row { + display: flex; + align-items: center; + gap: 6px; +} + .ai-customization-list-item .item-type-icon { flex-shrink: 0; width: 16px; @@ -489,6 +490,13 @@ min-width: 0; } +.ai-customization-list-item .item-name-row { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + .ai-customization-list-item .item-name { font-size: 13px; overflow: hidden; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d0b7fdefb7f..6ab781a848d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,6 @@ import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; -import { RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -238,9 +237,15 @@ configurationRegistry.registerConfiguration({ default: 0 }, [ChatConfiguration.AgentStatusEnabled]: { - type: 'boolean', - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the 'Agent Status' indicator is shown in the title bar command center. Enabling this setting will automatically enable {0}. The unread/in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), - default: true, + type: 'string', + enum: ['hidden', 'badge', 'compact'], + enumDescriptions: [ + nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), + nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), + nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), + ], + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), + default: 'compact', tags: ['experimental'] }, [ChatConfiguration.UnifiedAgentsBar]: { @@ -309,6 +314,11 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.RevealNextChangeOnResolve]: { + type: 'boolean', + markdownDescription: nls.localize('chat.editing.revealNextChangeOnResolve', "Controls whether the editor automatically reveals the next change after keeping or undoing a chat edit."), + default: true, + }, 'chat.tips.enabled': { type: 'boolean', scope: ConfigurationScope.APPLICATION, @@ -488,17 +498,11 @@ configurationRegistry.registerConfiguration({ type: 'boolean', tags: ['experimental'] }, - [ChatConfiguration.ImageCarouselEnabled]: { - default: true, - description: nls.localize('chat.imageCarousel.enabled', "Controls whether clicking an image attachment in chat opens the image carousel viewer."), - type: 'boolean', - tags: ['preview'] - }, [ChatConfiguration.ArtifactsEnabled]: { default: false, description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), type: 'boolean', - tags: ['preview'] + tags: ['experimental'] }, 'chat.undoRequests.restoreInput': { default: true, @@ -723,23 +727,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), default: false, - tags: ['experimental'], - included: product.quality !== 'stable', - }, - [RemoteAgentHostsSettingId]: { - type: 'array', - items: { - type: 'object', - properties: { - address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, - name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, - connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, - }, - required: ['address', 'name'], - }, - description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), - default: [], - tags: ['experimental'], + tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, [ChatConfiguration.PlanAgentDefaultModel]: { @@ -1319,10 +1307,20 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.SubagentsMaxDepth]: { + type: 'number', + description: nls.localize('chat.subagents.maxDepth', "Maximum nesting depth for subagents. Set to 0 to disable nested subagents. A subagent at this depth will not be able to launch further subagents."), + default: 0, + minimum: 0, + maximum: 20, + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.ChatCustomizationMenuEnabled]: { type: 'boolean', tags: ['preview'], - description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is available in the Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), + description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is enabled. When enabled, the gear icon in the Chat view opens the Customizations editor directly and additional actions are moved to the overflow menu. When disabled, the gear icon shows the legacy configuration dropdown."), default: true, }, [ChatConfiguration.ChatCustomizationHarnessSelectorEnabled]: { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 269ce660fb2..d2f6573afe6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -7,6 +7,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { Dimension } from '../../../../../base/browser/dom.js'; import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { ProgressBar } from '../../../../../base/browser/ui/progressbar/progressbar.js'; import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -20,7 +21,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; -import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles, defaultProgressBarStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js'; import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; import { filterDebugEventsByText } from '../../common/chatDebugEvents.js'; @@ -70,7 +71,7 @@ export class ChatDebugLogsView extends Disposable { private readonly eventListener = this._register(new MutableDisposable()); private readonly sessionStateDisposable = this._register(new MutableDisposable()); private readonly refreshScheduler: RunOnceScheduler; - private shimmerRow!: HTMLElement; + private readonly progressBar: ProgressBar; constructor( parent: HTMLElement, @@ -171,6 +172,12 @@ export class ChatDebugLogsView extends Disposable { DOM.append(this.tableHeader, $('span.chat-debug-col-name', undefined, localize('chatDebug.col.name', "Name"))); DOM.append(this.tableHeader, $('span.chat-debug-col-details', undefined, localize('chatDebug.col.details', "Details"))); + // Progress bar (shown when session is in progress) + this.progressBar = this._register(new ProgressBar(mainColumn, { + ...defaultProgressBarStyles, + ariaLabel: localize('chatDebug.progressAriaLabel', "Chat debug logs loading progress") + })); + // Body container this.bodyContainer = DOM.append(mainColumn, $('.chat-debug-logs-body')); @@ -228,13 +235,6 @@ export class ChatDebugLogsView extends Disposable { { identityProvider, accessibilityProvider } )); - // Shimmer row (positioned right below last row to indicate session is running) - this.shimmerRow = DOM.append(this.bodyContainer, $('.chat-debug-logs-shimmer-row')); - this.shimmerRow.setAttribute('aria-label', localize('chatDebug.loadingMore', "Loading more events…")); - this.shimmerRow.setAttribute('aria-busy', 'true'); - DOM.append(this.shimmerRow, $('span.chat-debug-logs-shimmer-bar')); - DOM.hide(this.shimmerRow); - // Detail panel (sibling of main column so it aligns with table header) this.detailPanel = this._register(this.instantiationService.createInstance(ChatDebugDetailPanel, contentContainer)); this._register(this.detailPanel.onDidChangeWidth(() => { @@ -366,11 +366,6 @@ export class ChatDebugLogsView extends Disposable { } else { this.refreshTree(filtered); } - this.updateShimmerPosition(filtered.length); - } - - private updateShimmerPosition(itemCount: number): void { - this.shimmerRow.style.top = `${itemCount * 28}px`; } addEvent(event: IChatDebugEvent): void { @@ -426,14 +421,14 @@ export class ChatDebugLogsView extends Disposable { private trackSessionState(): void { if (!this.currentSessionResource) { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); this.sessionStateDisposable.clear(); return; } const model = this.chatService.getSession(this.currentSessionResource); if (!model) { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); this.sessionStateDisposable.clear(); return; } @@ -441,9 +436,9 @@ export class ChatDebugLogsView extends Disposable { this.sessionStateDisposable.value = autorun(reader => { const inProgress = model.requestInProgress.read(reader); if (inProgress) { - DOM.show(this.shimmerRow); + this.progressBar.infinite(); } else { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); } }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index eade72c676f..5709e69513e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -331,6 +331,10 @@ .chat-debug-table-header .chat-debug-col-details { flex: 1; } +.chat-debug-logs-main > .monaco-progress-container { + height: 2px; + flex-shrink: 0; +} .chat-debug-logs-content { display: flex; flex-direction: row; @@ -402,31 +406,6 @@ .chat-debug-log-row.chat-debug-log-trace { opacity: 0.7; } -.chat-debug-logs-shimmer-row { - position: absolute; - left: 0; - right: 0; - display: flex; - align-items: center; - padding: 0 16px; - height: 28px; - gap: 40px; - pointer-events: none; -} -.chat-debug-logs-shimmer-bar { - flex: 1; - height: 10px; - border-radius: 3px; - background: linear-gradient( - 90deg, - var(--vscode-descriptionForeground) 25%, - var(--vscode-chat-thinkingShimmer, rgba(255, 255, 255, 0.3)) 50%, - var(--vscode-descriptionForeground) 75% - ); - background-size: 200% 100%; - animation: chat-debug-shimmer 2s linear infinite; - opacity: 0.15; -} .chat-debug-detail-panel { flex-shrink: 0; display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index 442e73d33f4..e539ca72eef 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -17,6 +17,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IListService } from '../../../../../platform/list/browser/listService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { resolveCommandsContext } from '../../../../browser/parts/editor/editorCommandsContext.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { EditorResourceAccessor, SideBySideEditor, TEXT_DIFF_EDITOR_ID } from '../../../../common/editor.js'; @@ -211,6 +212,7 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration): Promise { const instaService = accessor.get(IInstantiationService); + const configService = accessor.get(IConfigurationService); if (this._keep) { session.accept(entry.modifiedURI); @@ -218,7 +220,9 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { session.reject(entry.modifiedURI); } - await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); + if (configService.getValue(ChatConfiguration.RevealNextChangeOnResolve)) { + await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); + } } } @@ -270,6 +274,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise { const instaService = accessor.get(IInstantiationService); + const configService = accessor.get(IConfigurationService); if (this._accept) { await ctrl.acceptNearestChange(args[0] as IModifiedFileEntryChangeHunk | undefined); @@ -277,7 +282,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { await ctrl.rejectNearestChange(args[0] as IModifiedFileEntryChangeHunk | undefined); } - if (entry.changesCount.get() === 0) { + if (configService.getValue(ChatConfiguration.RevealNextChangeOnResolve) && entry.changesCount.get() === 0) { // no more changes, move to next file await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 48073f03acf..6dda2da6585 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -911,17 +911,28 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } } + private _safeCreateSnapshot(model: NotebookTextModel): string { + try { + return createSnapshot(model, this.transientOptions, this.configurationService); + } catch (e) { + this.loggingService.error('Notebook Chat', `Error creating snapshot: ${e instanceof Error ? e.message : e}`); + return this.initialContent; + } + } + public getCurrentSnapshot() { - return createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); + return this._safeCreateSnapshot(this.modifiedModel); } override createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry { + const original = this._safeCreateSnapshot(this.originalModel); + const current = this.getCurrentSnapshot(); return { resource: this.modifiedURI, languageId: SnapshotLanguageId, snapshotUri: getNotebookSnapshotFileURI(chatSessionResource, requestId, undoStop, this.modifiedURI.path, this.modifiedModel.viewType), - original: createSnapshot(this.originalModel, this.transientOptions, this.configurationService), - current: createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService), + original, + current, state: this.state.get(), telemetryInfo: this.telemetryInfo, }; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 8439b5518b7..04343f023ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -30,6 +30,7 @@ import { EditorActivation } from '../../../../../platform/editor/common/editor.j import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -47,7 +48,7 @@ import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; -import { FileOperation, FileOperationType } from './chatEditingOperations.js'; +import { FileOperation, FileOperationType, getKeyForChatSessionResource } from './chatEditingOperations.js'; import { IChatEditingExplanationModelManager, IExplanationDiffInfo, IExplanationGenerationHandle } from './chatEditingExplanationModelManager.js'; import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js'; import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; @@ -59,6 +60,25 @@ const enum NotExistBehavior { Abort, } +type ChatEditingSessionInfoEvent = { + editSessionId: string; + entryCount: number; + modifiedCount: number; + acceptedCount: number; + rejectedCount: number; +}; + +type ChatEditingSessionInfoClassification = { + owner: 'jrieken'; + comment: 'Tracks the number and state of chat editing entries when a session is stored.'; + editSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; + entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries stored with the session.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when storing.' }; + acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when storing.' }; + rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when storing.' }; +}; + + class ThrottledSequencer extends Sequencer { private _size = 0; @@ -199,6 +219,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IConfigurationService private readonly configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, @IChatEditingExplanationModelManager private readonly _explanationModelManager: IChatEditingExplanationModelManager, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._timeline = this._instantiationService.createInstance( @@ -308,7 +329,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource); - return storage.storeState(this._getStoredState()); + const storedState = this._getStoredState(); + this._telemetryService.publicLog2('chatEditing/sessionStore', { + editSessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(this._entriesObs.get()), + }); + return storage.storeState(storedState); } private _getStoredState(sessionResource = this.chatSessionResource): StoredSessionState { @@ -945,6 +971,10 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._entriesObs.set(entriesArr, undefined); + this._telemetryService.publicLog2('chatEditing/sessionRestore', { + editSessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(entriesArr), + }); } private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { @@ -981,6 +1011,28 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }; } + private _countEntryStates(entries: readonly AbstractChatEditingModifiedFileEntry[]): { entryCount: number; modifiedCount: number; acceptedCount: number; rejectedCount: number } { + let entryCount = 0; + let modifiedCount = 0; + let acceptedCount = 0; + let rejectedCount = 0; + for (const entry of entries) { + entryCount += 1; + switch (entry.state.get()) { + case ModifiedFileEntryState.Modified: + modifiedCount += 1; + break; + case ModifiedFileEntryState.Accepted: + acceptedCount += 1; + break; + case ModifiedFileEntryState.Rejected: + rejectedCount += 1; + break; + } + } + return { entryCount, modifiedCount, acceptedCount, rejectedCount }; + } + private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise { const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString()); if (!hasOtherTasks) { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 91da42a4866..b4ae5208966 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -7,7 +7,7 @@ import { sep } from '../../../../../base/common/path.js'; import { AsyncIterableProducer, raceCancellationError } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -31,7 +31,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ReadonlyChatSessionOptionsMap, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -47,7 +47,7 @@ import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; -import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -83,7 +83,7 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint; + private readonly _optionsCache: ChatSessionOptionsMap; public getOption(optionId: string): string | IChatSessionProviderOptionItem | undefined { return this._optionsCache.get(optionId); } @@ -250,17 +250,12 @@ class ContributedChatSessionData extends Disposable { readonly session: IChatSession, readonly chatSessionType: string, readonly resource: URI, - readonly options: Record | undefined, + readonly options: ReadonlyChatSessionOptionsMap | undefined, private readonly onWillDispose: (resource: URI) => void ) { super(); - this._optionsCache = new Map(); - if (options) { - for (const [key, value] of Object.entries(options)) { - this._optionsCache.set(key, value); - } - } + this._optionsCache = new Map(options); this._register(this.session.onWillDispose(() => { this.onWillDispose(this.resource); @@ -295,16 +290,14 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _onDidChangeContentProviderSchemes = this._register(new Emitter<{ readonly added: string[]; readonly removed: string[] }>()); public get onDidChangeContentProviderSchemes() { return this._onDidChangeContentProviderSchemes.event; } - private readonly _onDidChangeSessionOptions = this._register(new Emitter()); + private readonly _onDidChangeSessionOptions = this._register(new Emitter()); public get onDidChangeSessionOptions() { return this._onDidChangeSessionOptions.event; } private readonly _onDidChangeOptionGroups = this._register(new Emitter()); public get onDidChangeOptionGroups() { return this._onDidChangeOptionGroups.event; } - private readonly _onRequestNotifyExtension = this._register(new AsyncEmitter()); - public get onRequestNotifyExtension() { return this._onRequestNotifyExtension.event; } - private readonly inProgressMap: Map = new Map(); - private readonly _sessionTypeOptions: Map = new Map(); - private readonly _sessionTypeNewSessionOptions: Map> = new Map(); + private readonly inProgressMap = new Map(); + private readonly _sessionTypeOptions = new Map(); + private readonly _sessionTypeNewSessionOptions = new Map(); private readonly _sessions = new ResourceMap(); private readonly _resourceAliases = new ResourceMap(); // real resource -> untitled resource @@ -358,23 +351,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } })); - this._register(this.onDidChangeSessionItems((delta) => { - const changedChatSessionTypes = new Set(); - for (const session of delta.addedOrUpdated ?? []) { - changedChatSessionTypes.add(getChatSessionType(session.resource)); - } - - for (const resource of delta.removed ?? []) { - changedChatSessionTypes.add(getChatSessionType(resource)); - } - - for (const chatSessionType of changedChatSessionTypes) { - this.updateInProgressStatus(chatSessionType).catch(error => { - this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error); - }); - } - })); - this._register(this._labelService.registerFormatter({ scheme: Schemas.copilotPr, formatting: { @@ -385,27 +361,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ })); } - public reportInProgress(chatSessionType: string, count: number): void { - let displayName: string | undefined; - - if (chatSessionType === AgentSessionProviders.Local) { - displayName = localize('chat.session.inProgress.local', "Local Agent"); - } else if (chatSessionType === AgentSessionProviders.Background) { - displayName = localize('chat.session.inProgress.background', "Background Agent"); - } else if (chatSessionType === AgentSessionProviders.Cloud) { - displayName = localize('chat.session.inProgress.cloud', "Cloud Agent"); - } else { - displayName = this._contributions.get(chatSessionType)?.contribution.displayName; + private reportInProgress(chatSessionType: string, count: number): void { + if (!this._itemControllers.has(chatSessionType)) { + this._logService.warn(`Attempted to report in-progress status for unknown chat session type '${chatSessionType}'`); } - if (displayName) { - this.inProgressMap.set(displayName, count); - } + this.inProgressMap.set(chatSessionType, count); this._onDidChangeInProgress.fire(); } - public getInProgress(): { displayName: string; count: number }[] { - return Array.from(this.inProgressMap.entries()).map(([displayName, count]) => ({ displayName, count })); + public getInProgress(): { chatSessionType: string; count: number }[] { + return Array.from(this.inProgressMap.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } private async updateInProgressStatus(chatSessionType: string): Promise { @@ -926,12 +892,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ disposables.add(controller.onDidChangeChatSessionItems(e => { this._onDidChangeSessionItems.fire(e); + this.updateInProgressStatus(chatSessionType); })); - this.updateInProgressStatus(chatSessionType).catch(error => { - this._logService.warn(`Failed to update initial progress status for '${chatSessionType}':`, error); - }); - return { dispose: () => { initialRefreshCts.cancel(); @@ -942,6 +905,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._itemControllers.delete(chatSessionType); this._onDidChangeItemsProviders.fire({ chatSessionType }); } + + // Remove any in-progress tracking for this provider since it's no longer available + this.updateInProgressStatus(chatSessionType); } }; } @@ -1075,8 +1041,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); } - for (const [optionId, value] of Object.entries(session.options ?? {})) { - this.setSessionOption(sessionResource, optionId, value); + if (session.options) { + for (const [optionId, value] of session.options) { + this.setSessionOption(sessionResource, optionId, value); + } } // Make sure another session wasn't created while we were awaiting the provider @@ -1096,14 +1064,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._sessions.set(sessionResource, sessionData); // Make sure any listeners are aware of the new session and its options - this._onDidChangeSessionOptions.fire(sessionResource); + if (session.options) { + this._onDidChangeSessionOptions.fire({ sessionResource, updates: session.options }); + } return session; } public hasAnySessionOptions(sessionResource: URI): boolean { const session = this._sessions.get(this._resolveResource(sessionResource)); - return !!session && !!session.options && Object.keys(session.options).length > 0; + return !!session && !!session.options && session.options.size > 0; } public getSessionOptions(sessionResource: URI): Map | undefined { @@ -1124,8 +1094,28 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean { + return this.updateSessionOptions(sessionResource, new Map([[optionId, value]])); + } + + public updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean { const session = this._sessions.get(this._resolveResource(sessionResource)); - return !!session?.setOption(optionId, value); + if (!session) { + return false; + } + + let didChange = false; + for (const [optionId, value] of updates) { + const existingValue = session.getOption(optionId); + if (existingValue !== value) { + session.setOption(optionId, value); + didChange = true; + } + } + + if (didChange) { + this._onDidChangeSessionOptions.fire({ sessionResource, updates: updates }); + } + return didChange; } /** @@ -1160,31 +1150,12 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._sessionTypeOptions.get(chatSessionType); } - public getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined { - return this._sessionTypeNewSessionOptions.get(chatSessionType); + public getNewSessionOptionsForSessionType(chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined { + return new Map(this._sessionTypeNewSessionOptions.get(chatSessionType)); } - public setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void { - this._sessionTypeNewSessionOptions.set(chatSessionType, options); - } - - /** - * Notify extension about option changes for a session - */ - public async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { - if (!updates.length) { - return; - } - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: starting for ${sessionResource}, ${updates.length} update(s): [${updates.map(u => u.optionId).join(', ')}]`); - // Fire event to notify MainThreadChatSessions (which forwards to extension host) - // Uses fireAsync to properly await async listener work via waitUntil pattern - await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: fireAsync completed for ${sessionResource}`); - for (const u of updates) { - this.setSessionOption(sessionResource, u.optionId, u.value); - } - this._onDidChangeSessionOptions.fire(this._resolveResource(sessionResource)); - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: finished for ${sessionResource}`); + public setNewSessionOptionsForSessionType(chatSessionType: string, options: ReadonlyChatSessionOptionsMap): void { + this._sessionTypeNewSessionOptions.set(chatSessionType, new Map(options)); } /** @@ -1215,8 +1186,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public sessionSupportsFork(sessionResource: URI): boolean { - const resolved = this._resolveResource(sessionResource); - const session = this._sessions.get(resolved); + const session = this._sessions.get(sessionResource) + // Try to resolve in case an alias was used + ?? this._sessions.get(this._resolveResource(sessionResource)); return !!session?.session.forkSession; } @@ -1291,7 +1263,7 @@ export enum ChatSessionPosition { type NewChatSessionSendOptions = { readonly prompt: string; readonly attachedContext?: IChatRequestVariableEntry[]; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; }; export type NewChatSessionOpenOptions = { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 4a7157945cc..349ffc80a83 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -232,7 +232,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr }); } - override async run(accessor: ServicesAccessor, mode?: ChatModeKind | string, options?: { forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; inputValue?: string; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + override async run(accessor: ServicesAccessor, mode?: ChatModeKind | string, options?: { forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; inputValue?: string; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const widgetService = accessor.get(IChatWidgetService); const instantiationService = accessor.get(IInstantiationService); const dialogService = accessor.get(IDialogService); @@ -281,7 +281,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr }); } - override async run(accessor: ServicesAccessor, options?: { dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + override async run(accessor: ServicesAccessor, options?: { dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const commandService = accessor.get(ICommandService); const telemetryService = accessor.get(ITelemetryService); const chatEntitlementService = accessor.get(IChatEntitlementService); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 7e77a64f55e..dbf3a6895b7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -32,7 +32,6 @@ import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; -import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -51,7 +50,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService), accessor.get(IWorkbenchAssignmentService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService)); }); } @@ -75,14 +74,13 @@ export class ChatSetup { @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IHostService private readonly hostService: IHostService, - @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, ) { } skipDialog(): void { this.skipDialogOnce = true; } - async run(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + async run(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { if (this.pendingRun) { return this.pendingRun; } @@ -96,7 +94,7 @@ export class ChatSetup { } } - private async doRun(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + private async doRun(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { this.context.update({ later: false }); const dialogSkipped = this.skipDialogOnce; @@ -162,11 +160,10 @@ export class ChatSetup { return { success, dialogSkipped }; } - private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const disposables = new DisposableStore(); - const useCloseButton = options?.dialogHideSkip || await this.experimentService.getTreatment('chatSetupDialogCloseButton'); - const buttons = this.getButtons(options, useCloseButton); + const buttons = this.getButtons(options); const dialog = disposables.add(new Dialog( this.layoutService.activeContainer, @@ -178,8 +175,8 @@ export class ChatSetup { detail: ' ', // workaround allowing us to render the message in large icon: options?.dialogIcon ?? Codicon.copilotLarge, alignment: DialogContentsAlignment.Vertical, - cancelId: useCloseButton ? buttons.length : buttons.length - 1, - disableCloseButton: !useCloseButton, + cancelId: buttons.length, + disableCloseButton: false, renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)), buttonOptions: buttons.map(button => button[2]) }, this.keybindingService, this.layoutService, this.hostService) @@ -191,7 +188,7 @@ export class ChatSetup { return buttons[button]?.[1] ?? ChatSetupStrategy.Canceled; } - private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }, useCloseButton?: boolean): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { + private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { type ContinueWithButton = [string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]; const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) }); @@ -225,10 +222,6 @@ export class ChatSetup { buttons = [[localize('setupAIButton', "Use AI Features"), ChatSetupStrategy.DefaultSetup, undefined]]; } - if (!useCloseButton) { - buttons.push([localize('skipForNow', "Skip for now"), ChatSetupStrategy.Canceled, styleButton('link-button', 'skip-button')]); - } - return buttons; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 1111a9f58a2..b2bce851d6c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -12,6 +12,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IChatAgentService } from '../common/participants/chatAgents.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; import { IChatService } from '../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../common/constants.js'; @@ -28,6 +29,7 @@ import { IChatWidgetService } from './chat.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; import { Target } from '../common/promptSyntax/promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -163,7 +165,10 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - targets: [Target.VSCode, Target.GitHubCopilot] + when: ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.chatSessionSupportsFork + ), }, async (_prompt, _progress, _history, _location, sessionResource) => { await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource); })); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index ad2cb70765e..5b28e7c8a89 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -48,6 +48,7 @@ import { Color } from '../../../../../base/common/color.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; +import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; const defaultChat = product.defaultChatAgent; @@ -235,12 +236,15 @@ export class ChatStatusDashboard extends DomWidget { } })); - for (const { displayName, count } of inProgress) { + for (const { chatSessionType, count } of inProgress) { if (count > 0) { - const text = localize('inProgressChatSession', "$(loading~spin) {0} in progress", displayName); - const chatSessionsElement = this.element.appendChild($('div.description')); - const parts = renderLabelWithIcons(text); - chatSessionsElement.append(...parts); + const displayName = this.getDisplayNameForChatSessionType(chatSessionType); + if (displayName) { + const text = '$(loading~spin) ' + localize('inProgressChatSession', "{0} in progress", displayName); + const chatSessionsElement = this.element.appendChild($('div.description')); + const parts = renderLabelWithIcons(text); + chatSessionsElement.append(...parts); + } } } } @@ -402,6 +406,18 @@ export class ChatStatusDashboard extends DomWidget { } } + private getDisplayNameForChatSessionType(chatSessionType: string): string | undefined { + if (chatSessionType === AgentSessionProviders.Local) { + return localize('chat.session.inProgress.local', "Local Agent"); + } else if (chatSessionType === AgentSessionProviders.Background) { + return localize('chat.session.inProgress.background', "Background Agent"); + } else if (chatSessionType === AgentSessionProviders.Cloud) { + return localize('chat.session.inProgress.cloud', "Cloud Agent"); + } else { + return this.chatSessionsService.getChatSessionContribution(chatSessionType)?.displayName; + } + } + private canUseChat(): boolean { if (!this.chatEntitlementService.sentiment.installed || this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted) { return false; // chat not installed or not enabled diff --git a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts index 8109c362c3b..b33c3fe5278 100644 --- a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts @@ -67,7 +67,7 @@ export class ChatContextContribution extends Disposable implements IWorkbenchCon for (const contribution of ext.value) { const icon = contribution.icon ? ThemeIcon.fromString(contribution.icon) : undefined; if (!icon && contribution.icon) { - ext.collector.error(localize('chatContextExtPoint.invalidIcon', "Invalid icon format for chat context contribution '{0}'. Icon must be in the format '$(iconId)' or '$(iconId~spin)', e.g. '$(copilot)'.", contribution.id)); + ext.collector.error(localize('chatContextExtPoint.invalidIcon', "Invalid icon format for chat context contribution '{0}'. Icon must be in the format '{1}' or '{2}', e.g. '{3}'.", contribution.id, '$(iconId)', '$(iconId~spin)', '$(copilot)')); continue; } if (!icon) { diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts index 8ea0f30210d..383e3112e63 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginSources.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -8,7 +8,7 @@ import { CancelablePromise, timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { isWindows } from '../../../../base/common/platform.js'; -import { dirname, joinPath } from '../../../../base/common/resources.js'; +import { dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -79,18 +79,27 @@ abstract class AbstractGitPluginSource implements IPluginSource { protected abstract _displayLabel(descriptor: IPluginSourceDescriptor): string; getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined { + return this._getRepoDir(cacheRoot, descriptor); + } + + /** + * Returns the on-disk directory of the cloned repository. Subclasses that + * support a sub-path within a repository should override this to return the + * repository root, while {@link getInstallUri} returns root + sub-path. + */ + protected _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { return this.getInstallUri(cacheRoot, descriptor); } async ensure(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise { const descriptor = plugin.sourceDescriptor; - const repoDir = this.getInstallUri(cacheRoot, descriptor); + const repoDir = this._getRepoDir(cacheRoot, descriptor); const repoExists = await this._fileService.exists(repoDir); const label = this._displayLabel(descriptor); if (repoExists) { await this._checkoutRevision(repoDir, descriptor, options?.failureLabel ?? label); - return repoDir; + return this.getInstallUri(cacheRoot, descriptor); } const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", label); @@ -99,12 +108,12 @@ abstract class AbstractGitPluginSource implements IPluginSource { await this._cloneRepository(repoDir, this._cloneUrl(descriptor), progressTitle, failureLabel, ref); await this._checkoutRevision(repoDir, descriptor, failureLabel); - return repoDir; + return this.getInstallUri(cacheRoot, descriptor); } async update(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise { const descriptor = plugin.sourceDescriptor; - const repoDir = this.getInstallUri(cacheRoot, descriptor); + const repoDir = this._getRepoDir(cacheRoot, descriptor); const repoExists = await this._fileService.exists(repoDir); if (!repoExists) { this._logService.warn(`[${this.kind}] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`); @@ -242,14 +251,32 @@ export class RelativePathPluginSource implements IPluginSource { export class GitHubPluginSource extends AbstractGitPluginSource { readonly kind = PluginSourceKind.GitHub; + /** Returns the URI where the plugin content lives (repo root + optional sub-path). */ getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const repoDir = this._getRepoDir(cacheRoot, descriptor); + const gh = descriptor as IGitHubPluginSource; + if (gh.path) { + const normalizedPath = gh.path.trim().replace(/^\.?\/+|\/+$/g, ''); + if (normalizedPath) { + const target = joinPath(repoDir, normalizedPath); + if (isEqualOrParent(target, repoDir)) { + return target; + } + } + } + return repoDir; + } + + /** Returns the cloned repository root (without sub-path). */ + protected override _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { const gh = descriptor as IGitHubPluginSource; const [owner, repo] = gh.repo.split('/'); return joinPath(cacheRoot, 'github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha)); } getLabel(descriptor: IPluginSourceDescriptor): string { - return (descriptor as IGitHubPluginSource).repo; + const gh = descriptor as IGitHubPluginSource; + return gh.path ? `${gh.repo}/${gh.path}` : gh.repo; } protected _cloneUrl(descriptor: IPluginSourceDescriptor): string { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 45c8fa8c0a5..27cc2591d9c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -25,7 +25,7 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, SKILL_FILENAME, VALID_SKILL_NAME_REGEX } from '../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; @@ -327,7 +327,7 @@ class NewSkillFileAction extends Action2 { return localize('commands.new.skill.name.tooLong', "Skill name must be 64 characters or less"); } // Per spec: lowercase alphanumeric and hyphens only - if (!/^[a-z0-9-]+$/.test(name)) { + if (!VALID_SKILL_NAME_REGEX.test(name)) { return localize('commands.new.skill.name.invalidChars', "Skill name may only contain lowercase letters, numbers, and hyphens"); } if (name.startsWith('-') || name.endsWith('-')) { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index a2cfc90f7cd..b28c2c70f36 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -14,7 +14,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, getSkillFolderName } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../newPromptFileActions.js'; import { GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, GENERATE_PROMPT_COMMAND_ID, GENERATE_SKILL_COMMAND_ID, GENERATE_AGENT_COMMAND_ID } from '../../actions/chatActions.js'; @@ -569,7 +569,7 @@ export class PromptFilePickers { private async _createPromptPickItem(promptFile: IPromptPath, buttons: IQuickInputButton[] | undefined, visibility: boolean | undefined, token: CancellationToken): Promise { const parsedPromptFile = await this._promptsService.parseNew(promptFile.uri, token).catch(() => undefined); - let promptName = parsedPromptFile?.header?.name ?? promptFile.name ?? getCleanPromptName(promptFile.uri); + let promptName = (parsedPromptFile?.header?.name ?? promptFile.name) || (promptFile.type === PromptsType.skill ? getSkillFolderName(promptFile.uri) : getCleanPromptName(promptFile.uri)); const promptDescription = parsedPromptFile?.header?.description ?? promptFile.description; let tooltip: string | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts index fef06150ff0..0ddb27fa1ff 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -15,6 +15,7 @@ import { TextEdit } from '../../../../../editor/common/languages.js'; import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { rename } from '../../../../../editor/contrib/rename/browser/rename.js'; import { localize } from '../../../../../nls.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -53,6 +54,7 @@ export class RenameTool extends Disposable implements IToolImpl { constructor( @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ILanguageService private readonly _languageService: ILanguageService, @ITextModelService private readonly _textModelService: ITextModelService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IChatService private readonly _chatService: IChatService, @@ -67,26 +69,31 @@ export class RenameTool extends Disposable implements IToolImpl { )((() => this._onDidUpdateToolData.fire()))); } - getToolData(): IToolData { + getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.renameProvider.registeredLanguageIds; - let modelDescription = BaseModelDescription; - if (languageIds.has('*')) { - modelDescription += '\n\nSupported for all languages.'; - } else if (languageIds.size > 0) { - const sorted = [...languageIds].sort(); - modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; - } else { - modelDescription += '\n\nNo languages currently have rename providers registered.'; + if (languageIds.size === 0) { + return undefined; } + let modelDescription = BaseModelDescription; + let userDescription: string; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + userDescription = localize('tool.rename.userDescription', 'Rename a symbol across the workspace'); + } else { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + const niceNames = sorted.map(id => this._languageService.getLanguageName(id) ?? id); + userDescription = localize('tool.rename.userDescriptionWithLanguages', 'Rename a symbol across the workspace ({0})', niceNames.join(', ')); + } return { id: RenameToolId, toolReferenceName: 'rename', canBeReferencedInPrompt: false, icon: ThemeIcon.fromId(Codicon.rename.id), displayName: localize('tool.rename.displayName', 'Rename Symbol'), - userDescription: localize('tool.rename.userDescription', 'Rename a symbol across the workspace'), + userDescription, modelDescription, source: ToolDataSource.Internal, when: ContextKeyExpr.has('config.chat.tools.renameTool.enabled'), @@ -251,9 +258,12 @@ export class RenameToolContribution extends Disposable implements IWorkbenchCont let registration: IDisposable | undefined; const registerRenameTool = () => { registration?.dispose(); + registration = undefined; toolsService.flushToolUpdates(); const toolData = renameTool.getToolData(); - registration = toolsService.registerTool(toolData, renameTool); + if (toolData) { + registration = toolsService.registerTool(toolData, renameTool); + } }; registerRenameTool(); this._store.add(renameTool.onDidUpdateToolData(registerRenameTool)); diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts index 1360fbcba5c..96618b9afc4 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts @@ -14,7 +14,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { assertType, isObject } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { Action2 } from '../../../../../platform/actions/common/actions.js'; +import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -37,6 +37,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatViewId } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../common/constants.js'; const toolEnumValues: string[] = []; @@ -323,12 +324,18 @@ export class ConfigureToolSets extends Action2 { category: CHAT_CATEGORY, f1: true, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Tools.toolsCount.greater(0)), - menu: { + menu: [{ id: CHAT_CONFIG_MENU_ID, when: ContextKeyExpr.equals('view', ChatViewId), order: 11, group: '2_level' }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 11, + group: '2_level' + }], }); } diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts index 153763ce7b0..6f8f708ee01 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -18,6 +18,7 @@ import { Location, LocationLink } from '../../../../../editor/common/languages.j import { IModelService } from '../../../../../editor/common/services/model.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; import { localize } from '../../../../../nls.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -50,6 +51,7 @@ export class UsagesTool extends Disposable implements IToolImpl { constructor( @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ILanguageService private readonly _languageService: ILanguageService, @IModelService private readonly _modelService: IModelService, @ISearchService private readonly _searchService: ISearchService, @ITextModelService private readonly _textModelService: ITextModelService, @@ -64,17 +66,23 @@ export class UsagesTool extends Disposable implements IToolImpl { )((() => this._onDidUpdateToolData.fire()))); } - getToolData(): IToolData { + getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds; + if (languageIds.size === 0) { + return undefined; + } + let modelDescription = BaseModelDescription; + let userDescription: string; if (languageIds.has('*')) { modelDescription += '\n\nSupported for all languages.'; - } else if (languageIds.size > 0) { + userDescription = localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'); + } else { const sorted = [...languageIds].sort(); modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; - } else { - modelDescription += '\n\nNo languages currently have reference providers registered.'; + const niceNames = sorted.map(id => this._languageService.getLanguageName(id) ?? id); + userDescription = localize('tool.usages.userDescriptionWithLanguages', 'Find references, definitions, and implementations of a symbol ({0})', niceNames.join(', ')); } return { @@ -83,7 +91,7 @@ export class UsagesTool extends Disposable implements IToolImpl { canBeReferencedInPrompt: false, icon: ThemeIcon.fromId(Codicon.references.id), displayName: localize('tool.usages.displayName', 'List Code Usages'), - userDescription: localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'), + userDescription, modelDescription, source: ToolDataSource.Internal, when: ContextKeyExpr.has('config.chat.tools.usagesTool.enabled'), @@ -320,9 +328,12 @@ export class UsagesToolContribution extends Disposable implements IWorkbenchCont let registration: IDisposable | undefined; const registerUsagesTool = () => { registration?.dispose(); + registration = undefined; toolsService.flushToolUpdates(); const toolData = usagesTool.getToolData(); - registration = toolsService.registerTool(toolData, usagesTool); + if (toolData) { + registration = toolsService.registerTool(toolData, usagesTool); + } }; registerUsagesTool(); this._store.add(usagesTool.onDidUpdateToolData(registerUsagesTool)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts index 30394bfef46..709ee187d2e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; @@ -36,6 +36,8 @@ export class ChatArtifactsWidget extends Disposable { private _isCollapsed = true; private _list: WorkbenchList | undefined; private readonly _listStore = this._register(new DisposableStore()); + private _expandIcon!: HTMLElement; + private _titleElement!: HTMLElement; public static readonly ELEMENT_HEIGHT = 22; private static readonly MAX_ITEMS_SHOWN = 6; @@ -62,18 +64,24 @@ export class ChatArtifactsWidget extends Disposable { dom.clearNode(this.domNode); this._listStore.clear(); - const headerNode = dom.$('.chat-artifacts-header'); - this.domNode.appendChild(headerNode); + const expandoContainer = dom.$('.chat-artifacts-expand'); + const headerButton = this._listStore.add(new Button(expandoContainer, { supportIcons: true })); + headerButton.element.setAttribute('aria-expanded', String(!this._isCollapsed)); - const labelContainer = headerNode.appendChild(dom.$('.chat-artifacts-label')); - const headerButton = this._listStore.add(new ButtonWithIcon(labelContainer, {})); + const titleSection = dom.$('.chat-artifacts-title-section'); + this._expandIcon = dom.$('.expand-icon.codicon'); + this._expandIcon.classList.add(this._isCollapsed ? 'codicon-chevron-right' : 'codicon-chevron-down'); + this._expandIcon.setAttribute('aria-hidden', 'true'); + this._titleElement = dom.$('.chat-artifacts-title'); - this._listStore.add(headerButton.onDidClick(() => { - this._isCollapsed = !this._isCollapsed; - this._updateExpansionState(headerButton); - })); + titleSection.appendChild(this._expandIcon); + titleSection.appendChild(this._titleElement); + headerButton.element.appendChild(titleSection); + + this.domNode.appendChild(expandoContainer); const listContainer = dom.$('.chat-artifacts-list'); + listContainer.style.display = this._isCollapsed ? 'none' : 'block'; this.domNode.appendChild(listContainer); this._list = this._listStore.add(this._instantiationService.createInstance( @@ -95,7 +103,13 @@ export class ChatArtifactsWidget extends Disposable { } })); - this._updateExpansionState(headerButton); + this._listStore.add(headerButton.onDidClick(() => { + this._isCollapsed = !this._isCollapsed; + this._expandIcon.classList.toggle('codicon-chevron-down', !this._isCollapsed); + this._expandIcon.classList.toggle('codicon-chevron-right', this._isCollapsed); + headerButton.element.setAttribute('aria-expanded', String(!this._isCollapsed)); + listContainer.style.display = this._isCollapsed ? 'none' : 'block'; + })); this._autorunDisposable.value = autorun((reader: IReader) => { const artifacts: readonly IChatArtifact[] = this._currentObs!.read(reader); @@ -105,14 +119,14 @@ export class ChatArtifactsWidget extends Disposable { } this.domNode.style.display = ''; - headerButton.label = artifacts.length === 1 + this._titleElement.textContent = artifacts.length === 1 ? localize('chat.artifacts.one', "1 Artifact") : localize('chat.artifacts.count', "{0} Artifacts", artifacts.length); const itemsShown = Math.min(artifacts.length, ChatArtifactsWidget.MAX_ITEMS_SHOWN); const listHeight = itemsShown * ChatArtifactsWidget.ELEMENT_HEIGHT; this._list!.layout(listHeight); - listContainer.style.height = listHeight + 4 /* bottom padding */ + 'px'; + this._list!.getHTMLElement().style.height = `${listHeight}px`; this._list!.splice(0, this._list!.length, [...artifacts]); }); } @@ -144,11 +158,6 @@ export class ChatArtifactsWidget extends Disposable { }); } - private _updateExpansionState(headerButton: ButtonWithIcon): void { - headerButton.icon = this._isCollapsed ? Codicon.chevronRight : Codicon.chevronDown; - this.domNode.classList.toggle('chat-artifacts-collapsed', this._isCollapsed); - } - hide(): void { this._autorunDisposable.clear(); this.domNode.style.display = 'none'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 8197b51a71d..0ca080497f5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -15,7 +15,7 @@ import { coalesce } from '../../../../../../base/common/arrays.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { autorun, autorunSelfDisposable, derived } from '../../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; @@ -396,6 +396,13 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } + override dispose(): void { + super.dispose(); + + dispose(this.allRefs); + this.allRefs.length = 0; + } + private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined): IDisposableReference { const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock, sessionResource, requestId, inUndoStop); if (codemapperUri) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts index dcd85778a0e..cfa95551f1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts @@ -7,6 +7,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { disposableTimeout } from '../../../../../../base/common/async.js'; import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { basename, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -47,6 +48,8 @@ const IMAGE_DECODE_DELAY_MS = 100; */ export class ChatResourceGroupWidget extends Disposable { public readonly domNode: HTMLElement; + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; constructor( parts: IChatCollapsibleIODataPart[], @@ -124,6 +127,7 @@ export class ChatResourceGroupWidget extends Disposable { }; itemsContainer.appendChild(attachments.domNode!); + this._onDidChangeHeight.fire(); const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, MenuId.ChatToolOutputResourceToolbar, { menuOptions: { @@ -146,6 +150,7 @@ export class ChatResourceGroupWidget extends Disposable { // Update attachments in place attachments.updateVariables(entries); + this._onDidChangeHeight.fire(); }, IMAGE_DECODE_DELAY_MS)); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 67e0bbf0ce1..00b9f678dab 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -34,6 +34,9 @@ import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; import './media/chatThinkingContent.css'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; +import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; function extractTextFromPart(content: IChatThinkingPart): string { @@ -233,6 +236,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private lastKnownScrollTop: number = 0; private titleShimmerSpan: HTMLElement | undefined; private titleDetailContainer: HTMLElement | undefined; + private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget; private readonly _titleDetailRendered = this._register(new MutableDisposable()); private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { @@ -313,6 +317,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const node = this.domNode; node.classList.add('chat-thinking-box'); + this._externalResourceWidget = this._register(this.instantiationService.createInstance(ChatThinkingExternalResourceWidget)); + this._register(this._externalResourceWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + node.appendChild(this._externalResourceWidget.domNode); + if (!this.streamingCompleted && !this.element.isComplete) { if (!this.fixedScrollingMode) { node.classList.add('chat-thinking-active'); @@ -374,6 +382,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } + this._externalResourceWidget.setCollapsed(!isExpanded); + // Fire when expanded/collapsed this._onDidChangeHeight.fire(); })); @@ -1232,6 +1242,8 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } this.toolLabelsByCallId.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + this.updateDropdownClickability(); this._onDidChangeHeight.fire(); } @@ -1263,6 +1275,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): // Use the tracked displayed label (which may differ from invocationMessage // for streaming edit tools that show "Editing files") const toolCallId = removedItem.toolInvocationOrMarkdown.toolCallId; + this._externalResourceWidget.removeToolInvocation(toolCallId); const label = this.toolLabelsByCallId.get(toolCallId); if (label) { const titleIndex = this.extractedTitles.indexOf(label); @@ -1356,6 +1369,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.extractedTitles.splice(titleIndex, 1); } this.toolLabelsByCallId.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); this.updateDropdownClickability(); this._onDidChangeHeight.fire(); } @@ -1402,6 +1416,11 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): const toolCallId = toolInvocationOrMarkdown.toolCallId; this.toolLabelsByCallId.set(toolCallId, toolCallLabel); + // Render external image pills for serialized (already-completed) tool invocations + if (toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + } + // track state for live/still streaming tools, excluding serialized tools if (toolInvocationOrMarkdown.kind === 'toolInvocation') { let currentToolLabel = toolCallLabel; @@ -1462,6 +1481,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); this.schedulePendingRemovalsFlush(); } + + // Render image pills outside the collapsible area for completed tools + if (currentState.type === IChatToolInvocation.StateKind.Completed) { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + } + isComplete = true; return; } @@ -1526,6 +1551,22 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } } + private updateExternalResourceParts(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + const extractedImages = extractImagesFromToolInvocationOutputDetails(toolInvocation, this.element.sessionResource); + if (extractedImages.length === 0) { + return; + } + + const parts: IChatCollapsibleIODataPart[] = extractedImages.map(image => ({ + kind: 'data', + value: image.data.buffer, + mimeType: image.mimeType, + uri: image.uri, + })); + + this._externalResourceWidget.setToolInvocationParts(toolInvocation.toolCallId, parts); + } + private appendItemToDOM( content: HTMLElement, toolInvocationId?: string, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts new file mode 100644 index 00000000000..732ff270de5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, clearNode, hide, show } from '../../../../../../base/browser/dom.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ChatResourceGroupWidget } from './chatResourceGroupWidget.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; + +export class ChatThinkingExternalResourceWidget extends Disposable { + + public readonly domNode: HTMLElement; + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private readonly resourcePartsByToolCallId = new Map(); + private readonly resourceGroupWidget = this._register(new MutableDisposable()); + private readonly resourceGroupWidgetHeightListener = this._register(new MutableDisposable()); + private isCollapsed = true; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.domNode = $('.chat-thinking-external-resources'); + hide(this.domNode); + } + + public setToolInvocationParts(toolCallId: string, parts: IChatCollapsibleIODataPart[]): void { + if (parts.length === 0) { + return; + } + + this.resourcePartsByToolCallId.set(toolCallId, parts); + + this.rebuild(); + } + + public removeToolInvocation(toolCallId: string): void { + if (!this.resourcePartsByToolCallId.delete(toolCallId)) { + return; + } + + this.rebuild(); + } + + public setCollapsed(collapsed: boolean): void { + this.isCollapsed = collapsed; + + if (!this.resourceGroupWidget.value) { + hide(this.domNode); + return; + } + + if (this.isCollapsed) { + show(this.domNode); + } else { + hide(this.domNode); + } + } + + private rebuild(): void { + const allParts: IChatCollapsibleIODataPart[] = []; + for (const parts of this.resourcePartsByToolCallId.values()) { + allParts.push(...parts); + } + + this.resourceGroupWidgetHeightListener.clear(); + this.resourceGroupWidget.clear(); + clearNode(this.domNode); + + if (allParts.length === 0) { + hide(this.domNode); + this._onDidChangeHeight.fire(); + return; + } + + const widget = this.instantiationService.createInstance(ChatResourceGroupWidget, allParts); + this.resourceGroupWidgetHeightListener.value = widget.onDidChangeHeight(() => this._onDidChangeHeight.fire()); + this.resourceGroupWidget.value = widget; + this.domNode.appendChild(widget.domNode); + this.setCollapsed(this.isCollapsed); + this._onDidChangeHeight.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 3ae3f7dccea..656af1f8bf1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -247,31 +247,52 @@ background-color: var(--vscode-list-hoverBackground); } - /* Single-select: highlight entire row when selected */ + /* Single-select: highlight entire row when selected (list not focused) */ .chat-question-list-item.selected { - background-color: var(--vscode-list-hoverBackground); - color: var(--vscode-list-activeSelectionForeground); + background-color: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); - .chat-question-label { - color: var(--vscode-list-activeSelectionForeground); + .chat-question-list-label, + .chat-question-list-label-title { + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-label-desc { - color: var(--vscode-list-activeSelectionForeground); - opacity: 0.8; + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-number { - color: var(--vscode-list-activeSelectionForeground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } } + /* When the question list has focus, use active selection styling */ + .chat-question-list:focus .chat-question-list-item.selected { + background-color: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + + .chat-question-label { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-label-desc { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-number { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + } .chat-question-list-item.selected:hover { - background-color: var(--vscode-list-hoverBackground); + background-color: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-hoverBackground)); } /* Checkbox for multi-select */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 28990fb01f8..19684ae349f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -13,6 +13,11 @@ position: relative; color: var(--vscode-descriptionForeground); + .chat-thinking-external-resources { + margin-top: 4px; + margin-left: 5px; + } + .chat-used-context { margin: 0px; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 5d7b9dd0543..a0deb925c99 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { h } from '../../../../../../../base/browser/dom.js'; -import { renderLabelWithIcons } from '../../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ActionBar } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; -import { isMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { escapeMarkdownSyntaxTokens, isMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ChatConfiguration } from '../../../../common/constants.js'; @@ -1641,11 +1640,11 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart @IHoverService hoverService: IHoverService, @IConfigurationService configurationService: IConfigurationService, ) { - const title = isComplete ? `Ran \`${commandText}\`` : `Running \`${commandText}\``; + const title = isComplete ? `Ran \`${escapeMarkdownSyntaxTokens(commandText)}\`` : `Running \`${escapeMarkdownSyntaxTokens(commandText)}\``; super(title, context, undefined, hoverService, configurationService); this._terminalContentElement = contentElement; - this._commandText = commandText; + this._commandText = escapeMarkdownSyntaxTokens(commandText); this._isSandboxWrapped = isSandboxWrapped; this._isComplete = isComplete; @@ -1664,15 +1663,16 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart return; } - const labelElement = this._collapseButton.labelElement; - labelElement.textContent = ''; if (this._isSandboxWrapped) { - dom.reset(labelElement, ...renderLabelWithIcons(this._isComplete - ? localize('chat.terminal.ranInSandbox', "$(lock) Ran `{0}` in sandbox", this._commandText) - : localize('chat.terminal.runningInSandbox', "$(lock) Running `{0}` in sandbox", this._commandText))); + this._collapseButton.label = new MarkdownString(this._isComplete + ? '$(lock) ' + localize('chat.terminal.ranInSandbox', "Ran `{0}` in sandbox", this._commandText) + : '$(lock) ' + localize('chat.terminal.runningInSandbox', "Running `{0}` in sandbox", this._commandText), { supportThemeIcons: true }); return; } + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + const prefixText = this._isComplete ? localize('chat.terminal.ran.prefix', "Ran ") : localize('chat.terminal.running.prefix', "Running "); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts index 03c147cf351..9ba9846de1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts @@ -17,7 +17,7 @@ export function isMcpToolInvocation(toolInvocation: IChatToolInvocation | IChatT */ export function shouldShimmerForTool(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { if (isMcpToolInvocation(toolInvocation)) { - return true; + return !IChatToolInvocation.isComplete(toolInvocation); } if (toolInvocation.toolId === 'copilot_askQuestions' || toolInvocation.toolId === 'vscode_askQuestions') { return false; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 573b0b05d41..ea350214eac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1025,6 +1025,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part) && isMcpToolInvocation(part))) { + return false; + } + // Show if no content, only "used references", ends with a complete tool call, or ends with complete text edits and there is no incomplete tool call (edits are still being applied some time after they are all generated) const lastPart = findLast(partsToRender, part => part.kind !== 'markdownContent' || part.content.value.trim().length > 0); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 21f8316a2f8..ee6806961e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2776,11 +2776,18 @@ export class ChatWidget extends Disposable implements IChatWidget { */ private async _autoAttachInstructions({ attachedContext }: IChatRequestInputOptions): Promise { const contribution = this._lockedAgent ? this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id) : undefined; - if (!contribution?.autoAttachReferences) { + + // For contributed session types, default to false for autoAttachReferences. + const isContributedSession = !!contribution; + const autoAttachEnabled = isContributedSession ? + contribution.autoAttachReferences === true : true; + + if (!autoAttachEnabled) { this.logService.debug(`ChatWidget#_autoAttachInstructions: skipped, autoAttachReferences is disabled`); return; } - this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are always enabled`); + + this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are enabled`); const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined; const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined; const sessionResource = this._viewModel?.model.sessionResource; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index d89e48b88ca..55a40c0a6a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -563,7 +563,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // React to chat session option changes for the active session this._register(this.chatSessionsService.onDidChangeSessionOptions(e => { const sessionResource = this._widget?.viewModel?.model.sessionResource; - if (sessionResource && isEqual(sessionResource, e)) { + if (sessionResource && isEqual(sessionResource, e.sessionResource)) { // Options changed for our current session - refresh pickers this.refreshChatSessionPickers(); } @@ -704,10 +704,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge : mode.label.read(undefined) !== agentId; // Extensions use Label (name) as identifier for custom agents. } if (needsUpdate) { - this.chatSessionsService.notifySessionOptionsChange( + this.chatSessionsService.updateSessionOptions( ctx.chatSessionResource, - [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] - ).catch(err => this.logService.error('Failed to notify extension of agent change:', err)); + new Map([[agentOptionId, mode.isBuiltin ? '' : modeName]]) + ); } } } @@ -882,10 +882,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; const currentCtx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; if (currentCtx) { - this.chatSessionsService.notifySessionOptionsChange( - currentCtx.chatSessionResource, - [{ optionId: optionGroup.id, value: option }] - ).catch(err => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + this.chatSessionsService.setSessionOption(currentCtx.chatSessionResource, optionGroup.id, option); } // Refresh pickers to re-evaluate visibility of other option groups diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index b4f952eb197..dd43c63d365 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -14,12 +14,14 @@ import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IHoverPositionOptions } from '../../../../../../base/browser/ui/hover/hover.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; @@ -28,6 +30,7 @@ import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageM import { ChatEntitlement, IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; import * as semver from '../../../../../../base/common/semver/semver.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; +import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IUpdateService, StateType } from '../../../../../../platform/update/common/update.js'; function isVersionAtLeast(current: string, required: string): boolean { @@ -74,6 +77,18 @@ type ChatModelChangeEvent = { toModel: string | TelemetryTrustedValue; }; +type ChatModelPickerInteraction = 'disabledModelContactAdminClicked' | 'premiumModelUpgradePlanClicked' | 'otherModelsExpanded' | 'otherModelsCollapsed'; + +type ChatModelPickerInteractionClassification = { + owner: 'sandy081'; + comment: 'Reporting interactions in the chat model picker'; + interaction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model picker interaction that occurred' }; +}; + +type ChatModelPickerInteractionEvent = { + interaction: ChatModelPickerInteraction; +}; + function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, @@ -537,11 +552,13 @@ export class ModelPickerWidget extends Disposable { private readonly _hoverPosition: IHoverPositionOptions | undefined, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @ICommandService private readonly _commandService: ICommandService, + @IOpenerService private readonly _openerService: IOpenerService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IProductService private readonly _productService: IProductService, @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, @IUpdateService private readonly _updateService: IUpdateService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); @@ -632,6 +649,10 @@ export class ModelPickerWidget extends Disposable { const controlModelsForTier = isPro ? manifest.paid : manifest.free; const canShowManageModelsAction = this._delegate.showManageModelsAction() && shouldShowManageModelsAction(this._entitlementService); const manageModelsAction = canShowManageModelsAction ? createManageModelsAction(this._commandService) : undefined; + const logModelPickerInteraction = (interaction: ChatModelPickerInteraction) => { + this._telemetryService.publicLog2('chat.modelPickerInteraction', { interaction }); + }; + const manageSettingsUrl = this._productService.defaultChatAgent?.manageSettingsUrl; const items = buildModelPickerItems( models, this._selectedModel?.identifier, @@ -640,7 +661,7 @@ export class ModelPickerWidget extends Disposable { this._productService.version, this._updateService.state.type, onSelect, - this._productService.defaultChatAgent?.manageSettingsUrl, + manageSettingsUrl, this._delegate.useGroupedModelPicker(), !showFilter ? manageModelsAction : undefined, this._entitlementService, @@ -656,6 +677,19 @@ export class ModelPickerWidget extends Disposable { filterActions: showFilter && manageModelsAction ? [manageModelsAction] : undefined, focusFilterOnOpen: true, collapsedByDefault: new Set([ModelPickerSection.Other]), + onDidToggleSection: (section: string, collapsed: boolean) => { + if (section === ModelPickerSection.Other) { + logModelPickerInteraction(collapsed ? 'otherModelsCollapsed' : 'otherModelsExpanded'); + } + }, + linkHandler: (uri: URI) => { + if (uri.scheme === 'command' && uri.path === 'workbench.action.chat.upgradePlan') { + logModelPickerInteraction('premiumModelUpgradePlanClicked'); + } else if (manageSettingsUrl && this._uriIdentityService.extUri.isEqual(uri, URI.parse(manageSettingsUrl))) { + logModelPickerInteraction('disabledModelContactAdminClicked'); + } + void this._openerService.open(uri, { allowCommands: true }); + }, minWidth: 200, }; const previouslyFocusedElement = dom.getActiveElement(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 75ae8c409e4..32f493a7260 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -137,6 +137,9 @@ class SlashCommandCompletions extends Disposable { return { suggestions: slashCommands .filter(c => { + if (c.when && !widget.scopedContextKeyService.contextMatchesRules(c.when)) { + return false; + } if (!widget.lockedAgentId) { return true; } @@ -192,19 +195,21 @@ class SlashCommandCompletions extends Disposable { } return { - suggestions: slashCommands.map((c, i): CompletionItem => { - const withSlash = `${chatSubcommandLeader}${c.command}`; - return { - label: { label: withSlash, description: c.detail }, - insertText: c.executeImmediately ? '' : `${withSlash} `, - documentation: c.detail, - range, - filterText: `${chatAgentLeader}${c.command}`, - sortText: c.sortText ?? 'z'.repeat(i + 1), - kind: CompletionItemKind.Text, // The icons are disabled here anyway, - command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, - }; - }) + suggestions: slashCommands + .filter(c => !c.when || widget.scopedContextKeyService.contextMatchesRules(c.when)) + .map((c, i): CompletionItem => { + const withSlash = `${chatSubcommandLeader}${c.command}`; + return { + label: { label: withSlash, description: c.detail }, + insertText: c.executeImmediately ? '' : `${withSlash} `, + documentation: c.detail, + range, + filterText: `${chatAgentLeader}${c.command}`, + sortText: c.sortText ?? 'z'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway, + command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, + }; + }) }; } })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 9755ced0b24..8ec205d7f37 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -850,7 +850,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container, .interactive-input-part:has(.chat-todo-list-widget-container > .chat-todo-list-widget.has-todos) .chat-input-container, -.interactive-input-part:has(.chat-artifacts-widget-container > .chat-artifacts-widget) .chat-input-container, +.interactive-input-part:has(.chat-artifacts-widget-container > .chat-artifacts-widget:not([style*="display: none"])) .chat-input-container, .interactive-input-part:has(.chat-input-widgets-container > .chat-status-widget:not([style*="display: none"])) .chat-input-container, .interactive-input-part:has(.chat-getting-started-tip-container > .chat-tip-widget) .chat-input-container { /* Remove top border radius when editing session, todo list, or status widget is present */ @@ -879,12 +879,11 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; } -.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-editing-session .chat-editing-session-container { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.interactive-session .interactive-input-part > .chat-artifacts-widget-container + .chat-editing-session .chat-editing-session-container { +/* Remove top radius from widgets that follow another visible widget */ +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-artifacts-widget-container .chat-artifacts-widget, +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-editing-session .chat-editing-session-container, +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-artifacts-widget-container + .chat-editing-session .chat-editing-session-container, +.interactive-session .interactive-input-part > .chat-artifacts-widget-container:has(.chat-artifacts-widget:not([style*="display: none"])) + .chat-editing-session .chat-editing-session-container { border-top-left-radius: 0; border-top-right-radius: 0; } @@ -1114,8 +1113,10 @@ have to be updated for changes to the rules above, or to support more deeply nes } -.interactive-session .interactive-input-part > .chat-artifacts-widget-container:empty { - display: none; +.interactive-session .interactive-input-part > .chat-artifacts-widget-container { + margin-bottom: -4px; + width: 100%; + position: relative; } @@ -2161,43 +2162,67 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Chat artifacts widget — collapsible list of session artifacts */ .chat-artifacts-widget { + padding: 4px 3px 4px 3px; + box-sizing: border-box; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-bottom: none; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; display: flex; flex-direction: column; - border-radius: 4px; - border: 1px solid var(--vscode-chat-requestBorder); + gap: 2px; + overflow: hidden; } -.chat-artifacts-widget .chat-artifacts-header { - padding: 3px; +.chat-artifacts-widget .chat-artifacts-expand { width: 100%; +} + +.chat-artifacts-widget .chat-artifacts-expand .monaco-button { display: flex; - box-sizing: border-box; -} - -.chat-artifacts-widget .chat-artifacts-label { + align-items: center; + gap: 4px; + cursor: pointer; + justify-content: space-between; width: 100%; + background-color: transparent; + border-color: transparent; + color: var(--vscode-foreground); + padding: 0; + min-width: unset; } -.chat-artifacts-widget .chat-artifacts-label .monaco-button { - width: 100%; - border: none; - text-align: initial; - justify-content: initial; - gap: 0; +.chat-artifacts-widget .chat-artifacts-expand .monaco-button:focus:not(:focus-visible) { + outline: none; } -.chat-artifacts-widget .chat-artifacts-label .monaco-button .codicon { +.chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section { + padding-left: 3px; + display: flex; + align-items: center; + flex: 1; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 22px; +} + +.chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section .codicon { font-size: 16px; + line-height: 22px; + flex-shrink: 0; + margin-right: 3px; } .chat-artifacts-widget .chat-artifacts-list { width: 100%; - padding: 0 3px 4px; + padding: 0; box-sizing: border-box; } .chat-artifacts-widget .chat-artifacts-list .monaco-list .monaco-list-row { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .chat-artifacts-widget .chat-artifacts-list .monaco-list .monaco-list-row:hover { @@ -2226,10 +2251,6 @@ have to be updated for changes to the rules above, or to support more deeply nes font-size: 13px; } -.chat-artifacts-widget.chat-artifacts-collapsed .chat-artifacts-list { - display: none; -} - .interactive-session .checkpoint-file-changes-summary { display: flex; flex-direction: column; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index db74594fd35..bdc1c632cb0 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -8,11 +8,12 @@ import { $, addDisposableListener, append, EventHelper, EventType, getWindow, se import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { Orientation, Sash } from '../../../../../../base/browser/ui/sash/sash.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { autorun, IReader } from '../../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; @@ -97,6 +98,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private welcomeController: ChatViewWelcomeController | undefined; private restoringSession: Promise | undefined; + private readonly loadSessionCts = this._register(new MutableDisposable()); private readonly modelRef = this._register(new MutableDisposable()); private readonly activityBadge = this._register(new MutableDisposable()); @@ -262,7 +264,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { try { this._widget.setVisible(false); - await this.showModel(modelRef); + await this.showModel(CancellationToken.None, modelRef); } finally { this._widget.setVisible(wasVisible); } @@ -627,6 +629,16 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } })); + // When the currently displayed session is archived, start a new session + this._register(this.agentSessionsService.model.onDidChangeSessionArchivedState(e => { + if (e.isArchived()) { + const currentSessionResource = chatWidget.viewModel?.sessionResource; + if (currentSessionResource && isEqual(currentSessionResource, e.resource)) { + this.clear(); + } + } + })); + // When showing sessions stacked, adjust the height of the sessions list to make room for chat input this._register(autorun(reader => { chatWidget.inputPart.height.read(reader); @@ -687,29 +699,39 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private async _applyModel(): Promise { const sessionResource = this.getTransferredOrPersistedSessionInfo(); const modelRef = sessionResource ? await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None) : undefined; - await this.showModel(modelRef); + await this.showModel(CancellationToken.None, modelRef); } - private async showModel(modelRef?: IChatModelReference | undefined, startNewSession = true): Promise { + private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true): Promise { const oldModelResource = this.modelRef.value?.object.sessionResource; this.modelRef.value = undefined; let ref: IChatModelReference | undefined; if (startNewSession) { ref = modelRef ?? (this.chatService.transferredSessionResource - ? await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, CancellationToken.None) + ? await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, token) : this.chatService.startNewLocalSession(ChatAgentLocation.Chat)); if (!ref) { throw new Error('Could not start chat session'); } } + if (token.isCancellationRequested) { + ref?.dispose(); + return undefined; + } + this.modelRef.value = ref; const model = ref?.object; if (model) { await this.updateWidgetLockState(getChatSessionType(model.sessionResource)); // Update widget lock state based on session type + if (token.isCancellationRequested) { + this.modelRef.value = undefined; + return undefined; + } + // remember as model to restore in view state this.viewState.sessionResource = model.sessionResource; } @@ -760,46 +782,75 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } private async clear(): Promise { + // Cancel any in-flight loadSession call to prevent it from + // overwriting the fresh session we are about to create. + this.loadSessionCts.value?.cancel(); // Grab the widget's latest view state because it will be loaded back into the widget this.updateViewState(); - await this.showModel(undefined); + await this.showModel(CancellationToken.None); // Update the toolbar context with new sessionId this.updateActions(); } async loadSession(sessionResource: URI): Promise { + // Cancel any in-flight loadSession call so the last one always wins + this.loadSessionCts.value?.cancel(); + const cts = this.loadSessionCts.value = new CancellationTokenSource(); + const token = cts.token; + // Wait for any in-progress session restore (e.g. from onDidChangeAgents) // to finish first, so our showModel call is guaranteed to be the last one. if (this.restoringSession) { await this.restoringSession; } + if (token.isCancellationRequested) { + return undefined; + } + return this.progressService.withProgress({ location: ChatViewId, delay: 200 }, async () => { let queue: Promise = Promise.resolve(); // A delay here to avoid blinking because only Cloud sessions are slow, most others are fast const clearWidget = disposableTimeout(() => { + // Only clear the current model if this loadSession call is still the active one + // and has not been cancelled. This preserves the "last call wins" behavior. + if (token.isCancellationRequested || this.loadSessionCts.value !== cts) { + return; + } // clear current model without starting a new one - queue = this.showModel(undefined, false).then(() => { }); + queue = this.showModel(token, undefined, false).then(() => { }); }, 100); + const clearWidgetCancellationListener = token.onCancellationRequested(() => clearWidget.dispose()); try { - const newModelRef = await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + const newModelRef = await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, token); clearWidget.dispose(); await queue; - return this.showModel(newModelRef); + if (token.isCancellationRequested) { + newModelRef?.dispose(); + return undefined; + } + + return this.showModel(token, newModelRef); } catch (err) { clearWidget.dispose(); await queue; + if (token.isCancellationRequested) { + return undefined; + } + // Recover by starting a fresh empty session so the widget // is not left in a broken state without title or back button. this.logService.error(`Failed to load chat session '${sessionResource.toString()}'`, err); this.notificationService.error(localize('chat.loadSessionFailed', "Failed to open chat session: {0}", toErrorMessage(err))); - return this.showModel(undefined); + return this.showModel(token, undefined); + } finally { + clearWidgetCancellationListener.dispose(); } }); } diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 2b6c01066fe..9b2aadb806a 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -13,6 +13,17 @@ import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/ export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); +/** + * Extended storage type for AI Customization that includes built-in prompts + * shipped with the application, alongside the core `PromptsStorage` values. + */ +export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; + +/** + * Storage type discriminator for built-in customizations shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + /** * Possible section IDs for the AI Customization Management Editor sidebar. */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index f63c853a48b..6516c694984 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -31,6 +31,7 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { IChatRequestVariableValue } from '../attachments/chatVariables.js'; import { ChatAgentLocation } from '../constants.js'; import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult, IToolResultInputOutputDetails, ToolDataSource } from '../tools/languageModelToolsService.js'; +import { ReadonlyChatSessionOptionsMap } from '../chatSessionsService.js'; export interface IChatRequest { message: string; @@ -1203,35 +1204,35 @@ export interface IChatCompleteResponse { } export interface IChatSessionStats { - fileCount: number; - added: number; - removed: number; + readonly fileCount: number; + readonly added: number; + readonly removed: number; } export type IChatSessionTiming = { /** * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. */ - created: number; + readonly created: number; /** * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * * Should be undefined if no requests have been made yet. */ - lastRequestStarted: number | undefined; + readonly lastRequestStarted: number | undefined; /** * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * * Should be undefined if the most recent request is still in progress or if no requests have been made yet. */ - lastRequestEnded: number | undefined; + readonly lastRequestEnded: number | undefined; }; interface ILegacyChatSessionTiming { - startTime: number; - endTime?: number; + readonly startTime: number; + readonly endTime?: number; } export function convertLegacyChatSessionTiming(timing: IChatSessionTiming | ILegacyChatSessionTiming): IChatSessionTiming { @@ -1541,7 +1542,7 @@ export interface IChatService { export interface IChatSessionContext { readonly chatSessionResource: URI; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; } export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index a1d839b05b0..6aea62fe4eb 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -830,8 +830,7 @@ export class ChatService extends Disposable implements IChatService { // Capture session options before loading the remote session, // since the alias registration below may change the lookup. - const sessionOptions = this.chatSessionService.getSessionOptions(sessionResource); - const initialSessionOptions = sessionOptions ? [...sessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined; + const initialSessionOptions = this.chatSessionService.getSessionOptions(sessionResource); const newItem = await this.chatSessionService.createNewChatSessionItem(getChatSessionType(sessionResource), { prompt: requestText, command: commandPart?.text, initialSessionOptions }, CancellationToken.None); if (newItem) { @@ -847,7 +846,7 @@ export class ChatService extends Disposable implements IChatService { // so that the agent receives them when invoked. model.setContributedChatSession({ chatSessionResource: newItem.resource, - initialSessionOptions: sessionOptions ? [...sessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined, + initialSessionOptions: initialSessionOptions, }); sessionResource = newItem.resource; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 1220250312b..bc68d0d77c7 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Event, IWaitUntil } from '../../../../base/common/event.js'; +import { Event } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; @@ -25,48 +25,48 @@ export const enum ChatSessionStatus { } export interface IChatSessionCommandContribution { - name: string; - description: string; - when?: string; + readonly name: string; + readonly description: string; + readonly when?: string; } export interface IChatSessionProviderOptionItem { - id: string; - name: string; - description?: string; - locked?: boolean; - icon?: ThemeIcon; - default?: boolean; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly locked?: boolean; + readonly icon?: ThemeIcon; + readonly default?: boolean; // [key: string]: any; } export interface IChatSessionProviderOptionGroupCommand { - command: string; - title: string; - tooltip?: string; - arguments?: unknown[]; + readonly command: string; + readonly title: string; + readonly tooltip?: string; + readonly arguments?: readonly unknown[]; } export interface IChatSessionProviderOptionGroup { - id: string; - name: string; - description?: string; - items: IChatSessionProviderOptionItem[]; - searchable?: boolean; - onSearch?: (query: string, token: CancellationToken) => Thenable; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly items: readonly IChatSessionProviderOptionItem[]; + readonly searchable?: boolean; + readonly onSearch?: (query: string, token: CancellationToken) => Thenable; /** * A context key expression that controls visibility of this option group picker. * When specified, the picker is only visible when the expression evaluates to true. * The expression can reference other option group values via `chatSessionOption.`. * Example: `"chatSessionOption.models == 'gpt-4'"` */ - when?: string; - icon?: ThemeIcon; + readonly when?: string; + readonly icon?: ThemeIcon; /** * Custom commands to show in the option group's picker UI. * These will be shown in a separate section at the end of the picker. */ - commands?: IChatSessionProviderOptionGroupCommand[]; + readonly commands?: readonly IChatSessionProviderOptionGroupCommand[]; } export interface IChatSessionsExtensionPoint { @@ -107,28 +107,28 @@ export interface IChatSessionsExtensionPoint { } export interface IChatSessionItem { - resource: URI; - label: string; - iconPath?: ThemeIcon; - badge?: string | IMarkdownString; - description?: string | IMarkdownString; - status?: ChatSessionStatus; - tooltip?: string | IMarkdownString; - timing: IChatSessionTiming; - changes?: { - files: number; - insertions: number; - deletions: number; + readonly resource: URI; + readonly label: string; + readonly iconPath?: ThemeIcon; + readonly badge?: string | IMarkdownString; + readonly description?: string | IMarkdownString; + readonly status?: ChatSessionStatus; + readonly tooltip?: string | IMarkdownString; + readonly timing: IChatSessionTiming; + readonly changes?: { + readonly files: number; + readonly insertions: number; + readonly deletions: number; } | readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]; - archived?: boolean; - metadata?: { readonly [key: string]: unknown }; + readonly archived?: boolean; + readonly metadata?: { readonly [key: string]: unknown }; } export interface IChatSessionFileChange { - modifiedUri: URI; - originalUri?: URI; - insertions: number; - deletions: number; + readonly modifiedUri: URI; + readonly originalUri?: URI; + readonly insertions: number; + readonly deletions: number; } export interface IChatSessionFileChange2 { @@ -174,11 +174,8 @@ export interface IChatSession extends IDisposable { readonly history: readonly IChatSessionHistoryItem[]; - /** - * Session options as key-value pairs. Keys correspond to option group IDs (e.g., 'models', 'subagents') - * and values are either the selected option item IDs (string) or full option items (for locked state). - */ - readonly options?: Record; + + readonly options?: ReadonlyChatSessionOptionsMap; readonly progressObs?: IObservable; readonly isCompleteObs?: IObservable; @@ -188,8 +185,8 @@ export interface IChatSession extends IDisposable { * Editing session transferred from a previously-untitled chat session in `onDidCommitChatSessionItem`. */ transferredState?: { - editingSession: IChatEditingSession | undefined; - inputState: ISerializableChatModelInputState | undefined; + readonly editingSession: IChatEditingSession | undefined; + readonly inputState: ISerializableChatModelInputState | undefined; }; requestHandler?: ( @@ -217,7 +214,7 @@ export interface IChatNewSessionRequest { readonly prompt: string; readonly command?: string; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; } export interface IChatSessionItemsDelta { @@ -236,19 +233,49 @@ export interface IChatSessionItemController { newChatSessionItem?(request: IChatNewSessionRequest, token: CancellationToken): Promise; } -/** - * Event fired when session options need to be sent to the extension. - * Extends IWaitUntil to allow listeners to register async work that will be awaited. - */ -export interface IChatSessionOptionsWillNotifyExtensionEvent extends IWaitUntil { +export interface IChatSessionOptionsChangeEvent { readonly sessionResource: URI; - readonly updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; + readonly updates: ReadonlyMap; } export type ResolvedChatSessionsExtensionPoint = Omit & { readonly icon: ThemeIcon | URI | undefined; }; +/** + * Session options as key-value pairs. + * + * Keys correspond to option group IDs (e.g., 'models', 'subagents') and values are either the selected option item IDs (string) or full option items (for locked state). + */ +export type ChatSessionOptionsMap = Map; + +export namespace ChatSessionOptionsMap { + export function fromRecord(obj: { [key: string]: string | IChatSessionProviderOptionItem }): ChatSessionOptionsMap { + return new Map(Object.entries(obj)); + } + + export function toRecord(map: ReadonlyChatSessionOptionsMap): Record { + const record: Record = Object.create(null); + for (const [key, value] of map) { + record[key] = value; + } + return record; + } + + export function toStrValueArray(map: ReadonlyChatSessionOptionsMap | undefined): Array<{ optionId: string; value: string }> | undefined { + if (!map) { + return undefined; + } + return Array.from(map, ([optionId, value]) => ({ optionId, value: typeof value === 'string' ? value : value.id })); + } +} + +/** + * Readonly version of {@link ChatSessionOptionsMap} + */ +export type ReadonlyChatSessionOptionsMap = ReadonlyMap; + + export const IChatSessionsService = createDecorator('chatSessionsService'); export interface IChatSessionsService { @@ -288,8 +315,8 @@ export interface IChatSessionsService { */ refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise; - reportInProgress(chatSessionType: string, count: number): void; - getInProgress(): { displayName: string; count: number }[]; + /** @deprecated Use `getChatSessionItems` */ + getInProgress(): { chatSessionType: string; count: number }[]; // #endregion @@ -303,14 +330,15 @@ export interface IChatSessionsService { getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise; hasAnySessionOptions(sessionResource: URI): boolean; - getSessionOptions(sessionResource: URI): Map | undefined; + getSessionOptions(sessionResource: URI): ReadonlyChatSessionOptionsMap | undefined; getSessionOption(sessionResource: URI, optionId: string): string | IChatSessionProviderOptionItem | undefined; setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean; + updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean; /** * Fired when options for a chat session change. */ - readonly onDidChangeSessionOptions: Event; + readonly onDidChangeSessionOptions: Event; /** * Get the capabilities for a specific session type @@ -353,15 +381,8 @@ export interface IChatSessionsService { getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; - getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined; - setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void; - /** - * Event fired when session options change and need to be sent to the extension. - * MainThreadChatSessions subscribes to this to forward changes to the extension host. - * Uses IWaitUntil pattern to allow listeners to register async work. - */ - readonly onRequestNotifyExtension: Event; - notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise; + getNewSessionOptionsForSessionType(chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined; + setNewSessionOptionsForSessionType(chatSessionType: string, options: ReadonlyChatSessionOptionsMap): void; getInProgressSessionDescription(chatModel: IChatModel): string | undefined; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index a2303956550..75d30b88a44 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -48,15 +48,17 @@ export enum ChatConfiguration { ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', ChatContextUsageEnabled = 'chat.contextUsage.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', + SubagentsMaxDepth = 'chat.subagents.maxDepth', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', + RevealNextChangeOnResolve = 'chat.editing.revealNextChangeOnResolve', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', AutopilotEnabled = 'chat.autopilot.enabled', - ImageCarouselEnabled = 'chat.imageCarousel.enabled', + ImageCarouselEnabled = 'imageCarousel.chat.enabled', ArtifactsEnabled = 'chat.artifacts.enabled', } diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 0e4616251d6..a0dd5106900 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -1740,7 +1740,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelExistsInCache(entry.id) }; + free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -1750,7 +1750,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelExistsInCache(entry.id) }; + paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -1758,17 +1758,7 @@ export class LanguageModelsService implements ILanguageModelsService { this._onDidChangeModelsControlManifest.fire(this._modelsControlManifest); } - private _modelExistsInCache(metadataId: string): boolean { - for (const model of this._modelCache.values()) { - if (model.id === metadataId) { - return true; - } - } - return false; - } - //#region Chat control data - private _initChatControlData(): void { this._chatControlUrl = this._productService.chatParticipantRegistry; if (!this._chatControlUrl) { diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 4124516612e..e5ce38c5fea 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -862,6 +862,9 @@ export class Response extends AbstractResponse implements IDisposable { ); if (existingInvocation) { + if (progress.toolSpecificData !== undefined) { + existingInvocation.toolSpecificData = progress.toolSpecificData; + } if (progress.isComplete) { existingInvocation.didExecuteTool({ content: [], @@ -870,9 +873,6 @@ export class Response extends AbstractResponse implements IDisposable { toolResultDetails: progress.resultDetails }); } - if (progress.toolSpecificData !== undefined) { - existingInvocation.toolSpecificData = progress.toolSpecificData; - } return; } @@ -900,15 +900,15 @@ export class Response extends AbstractResponse implements IDisposable { if (progress.isComplete) { // Already completed on first push + if (progress.toolSpecificData !== undefined) { + invocation.toolSpecificData = progress.toolSpecificData; + } invocation.didExecuteTool({ content: [], toolResultMessage: progress.pastTenseMessage, toolResultError: progress.errorMessage, toolResultDetails: progress.resultDetails }); - if (progress.toolSpecificData !== undefined) { - invocation.toolSpecificData = progress.toolSpecificData; - } } this._responseParts.push(invocation); diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index f907b5afc48..d517710e084 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { IChatMessage } from '../languageModels.js'; @@ -39,6 +40,12 @@ export interface IChatSlashData { locations: ChatAgentLocation[]; modes?: ChatModeKind[]; targets?: Target[]; + + /** + * Optional context key expression that controls visibility of this command. + * When set, the command is only shown if the expression evaluates to true. + */ + when?: ContextKeyExpression; } export interface IChatSlashFragment { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts index ea90fb6f832..7ad90d2fd26 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts @@ -102,9 +102,13 @@ export interface IAgentPluginRepositoryService { * the marketplace repository cache). For direct sources (github, url, npm, * pip) the cache directory is deleted. * + * When {@link otherInstalledDescriptors} is provided, deletion is skipped + * if any of those descriptors share the same cleanup target directory + * (e.g. multiple plugins installed from the same cloned repository). + * * This is best-effort: failures are logged but do not throw. */ - cleanupPluginSource(plugin: IMarketplacePlugin): Promise; + cleanupPluginSource(plugin: IMarketplacePlugin, otherInstalledDescriptors?: readonly IPluginSourceDescriptor[]): Promise; /** * Silently fetches remote refs for a cloned marketplace repository and diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 392d86f6139..3f531617166 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -20,6 +20,8 @@ export const IAgentPluginService = createDecorator('agentPl export interface IAgentPluginHook { readonly type: HookType; readonly hooks: readonly IHookCommand[]; + /** URI where this hook is defined -- not unique, multiple hooks may be in a manifest */ + readonly uri: URI; readonly originalId: string; } @@ -46,6 +48,7 @@ export interface IAgentPluginInstruction { export interface IAgentPluginMcpServerDefinition { readonly name: string; readonly configuration: IMcpServerConfiguration; + readonly uri: URI; } export interface IAgentPlugin { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index c044c2d0cd5..934888a9cce 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -57,11 +57,11 @@ interface IAgentPluginFormatAdapter { readonly hookConfigPath: string; readonly pluginRootToken: string | undefined; readonly pluginRootEnvVar: string | undefined; - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[]; + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[]; } -function mapParsedHooks(parsed: Map): IAgentPluginHook[] { - return [...parsed.entries()].map(([type, { hooks, originalId }]) => ({ type, hooks, originalId })); +function mapParsedHooks(uri: URI, parsed: Map): IAgentPluginHook[] { + return [...parsed.entries()].map(([type, { hooks, originalId }]) => ({ type, uri, hooks, originalId })); } /** @@ -85,9 +85,9 @@ class CopilotPluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService); - return mapParsedHooks(parseCopilotHooks(json, workspaceRoot, userHome)); + return mapParsedHooks(hookURI, parseCopilotHooks(json, workspaceRoot, userHome)); } } @@ -201,7 +201,7 @@ function interpolateMcpPluginRoot( interpolated = remote; } - return { name: def.name, configuration: interpolated }; + return { name: def.name, configuration: interpolated, uri: def.uri }; } /** @@ -211,6 +211,7 @@ function interpolateMcpPluginRoot( * delegates to {@link parseClaudeHooks} for the actual hook resolution. */ function parsePluginRootHooks( + hookURI: URI, json: unknown, pluginUri: URI, userHome: string, @@ -265,7 +266,7 @@ function parsePluginRootHooks( return []; } - return mapParsedHooks(hooks); + return mapParsedHooks(hookURI, hooks); } class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { @@ -279,8 +280,8 @@ class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { - return parsePluginRootHooks(json, pluginUri, userHome, this._workspaceContextService, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + return parsePluginRootHooks(hookURI, json, pluginUri, userHome, this._workspaceContextService, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); } } @@ -295,8 +296,8 @@ class OpenPluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { - return parsePluginRootHooks(json, pluginUri, userHome, this._workspaceContextService, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + return parsePluginRootHooks(hookURI, json, pluginUri, userHome, this._workspaceContextService, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); } } @@ -584,6 +585,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements return result.recomputeInitiallyAndOnChange(store); }; + const manifestUri = joinPath(uri, adapter.manifestPath); const commands = observeComponent('commands', d => this._readMarkdownComponents(d)); const skills = observeComponent('skills', d => this._readSkills(uri, d)); const agents = observeComponent('agents', d => this._readMarkdownComponents(d)); @@ -593,7 +595,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements paths => this._readHooksFromPaths(uri, paths, adapter), async section => { const userHome = (await this._pathService.userHome()).fsPath; - return adapter.parseHooks(section, uri, userHome); + return adapter.parseHooks(manifestUri, section, uri, userHome); }, adapter.hookConfigPath, ); @@ -601,7 +603,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const mcpServerDefinitions = observeComponent( 'mcpServers', paths => this._readMcpDefinitionsFromPaths(paths, uri.fsPath, adapter), - async section => this._parseMcpServerDefinitionMap({ mcpServers: section }, uri.fsPath, adapter), + async section => this._parseMcpServerDefinitionMap(manifestUri, { mcpServers: section }, uri.fsPath, adapter), '.mcp.json', ); @@ -611,7 +613,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements }; const manifestWatcher = this._fileService.createWatcher( - joinPath(uri, adapter.manifestPath), + manifestUri, { recursive: false, excludes: [] }, ); store.add(manifestWatcher); @@ -657,7 +659,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const json = await this._readJsonFile(hookPath); if (json) { try { - return adapter.parseHooks(json, pluginUri, userHome); + return adapter.parseHooks(hookPath, json, pluginUri, userHome); } catch (e) { this._logService.info(`[AgentPluginDiscovery] Failed to parse hooks from ${hookPath.toString()}:`, e); } @@ -672,21 +674,19 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements * server name wins. */ private async _readMcpDefinitionsFromPaths(paths: readonly URI[], pluginFsPath: string, adapter: IAgentPluginFormatAdapter): Promise { - const merged = new Map(); + const merged = new Map(); for (const mcpPath of paths) { const json = await this._readJsonFile(mcpPath); - for (const def of this._parseMcpServerDefinitionMap(json, pluginFsPath, adapter)) { + for (const def of this._parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, adapter)) { if (!merged.has(def.name)) { - merged.set(def.name, def.configuration); + merged.set(def.name, def); } } } - return [...merged.entries()] - .map(([name, configuration]) => ({ name, configuration } satisfies IAgentPluginMcpServerDefinition)) - .sort((a, b) => a.name.localeCompare(b.name)); + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); } - private _parseMcpServerDefinitionMap(raw: unknown, pluginFsPath: string, adapter: IAgentPluginFormatAdapter): IAgentPluginMcpServerDefinition[] { + private _parseMcpServerDefinitionMap(definitionURI: URI, raw: unknown, pluginFsPath: string, adapter: IAgentPluginFormatAdapter): IAgentPluginMcpServerDefinition[] { const mcpServers = resolveMcpServersMap(raw); if (!mcpServers) { return []; @@ -699,7 +699,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements continue; } - let def: IAgentPluginMcpServerDefinition = { name, configuration }; + let def: IAgentPluginMcpServerDefinition = { name, configuration, uri: definitionURI }; if (adapter.pluginRootToken && adapter.pluginRootEnvVar) { def = interpolateMcpPluginRoot(def, pluginFsPath, adapter.pluginRootToken, adapter.pluginRootEnvVar); } @@ -1120,7 +1120,14 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover remove: () => { this._enablementModel.remove(stat.resource.toString()); this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri); - this._pluginRepositoryService.cleanupPluginSource(entry.plugin).catch(error => { + + // Pass remaining installed descriptors so the repository service + // can skip deletion when other plugins share the same cache dir. + const remaining = this._pluginMarketplaceService.installedPlugins.get(); + this._pluginRepositoryService.cleanupPluginSource( + entry.plugin, + remaining.map(e => e.plugin.sourceDescriptor), + ).catch(error => { this._logService.error('[MarketplaceAgentPluginDiscovery] Failed to clean up plugin source', error); }); }, diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 49a873828a9..b67baada8a6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -57,6 +57,7 @@ export interface IGitHubPluginSource { readonly repo: string; readonly ref?: string; readonly sha?: string; + readonly path?: string; } export interface IGitUrlPluginSource { @@ -113,6 +114,7 @@ interface IJsonPluginSource { readonly package?: string; readonly ref?: string; readonly sha?: string; + readonly path?: string; readonly version?: string; readonly registry?: string; } @@ -840,11 +842,16 @@ export function parsePluginSource( logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'sha' must be a full 40-character commit hash when provided`); return undefined; } + if (!isOptionalString(rawSource.path)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'path' must be a string when provided`); + return undefined; + } return { kind: PluginSourceKind.GitHub, repo: rawSource.repo, ref: rawSource.ref, sha: rawSource.sha, + path: rawSource.path, }; } case 'url': { @@ -930,7 +937,7 @@ export function getPluginSourceLabel(descriptor: IPluginSourceDescriptor): strin case PluginSourceKind.RelativePath: return descriptor.path || '.'; case PluginSourceKind.GitHub: - return descriptor.repo; + return descriptor.path ? `${descriptor.repo}/${descriptor.path}` : descriptor.repo; case PluginSourceKind.GitUrl: return descriptor.url; case PluginSourceKind.Npm: @@ -952,7 +959,8 @@ export function hasSourceChanged(installed: IPluginSourceDescriptor, marketplace switch (installed.kind) { case PluginSourceKind.GitHub: return installed.ref !== (marketplace as typeof installed).ref - || installed.sha !== (marketplace as typeof installed).sha; + || installed.sha !== (marketplace as typeof installed).sha + || installed.path !== (marketplace as typeof installed).path; case PluginSourceKind.GitUrl: return installed.ref !== (marketplace as typeof installed).ref || installed.sha !== (marketplace as typeof installed).sha; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 0cfdc0b14d3..dafa487c65b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -23,7 +23,7 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariable import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; import { isInClaudeAgentsFolder, isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; -import { ParsedPromptFile } from './promptFileParser.js'; +import { ParsedPromptFile, PromptHeader } from './promptFileParser.js'; import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; @@ -233,21 +233,6 @@ export class ComputeAutomaticInstructions { } } - /** - * Combines the `applyTo` and `paths` attributes into a single comma-separated - * pattern string that can be matched by {@link _matches}. - * Used for the instructions list XML output where both should be shown. - */ - private _getApplyToPattern(applyTo: string | undefined, paths: readonly string[] | undefined): string | undefined { - if (applyTo) { - return applyTo; - } - if (paths && paths.length > 0) { - return paths.join(', '); - } - return undefined; - } - private _matches(files: ResourceSet, applyToPattern: string): { pattern: string; file?: URI } | undefined { const patterns = splitGlobAware(applyToPattern, ','); const patterMatches = (pattern: string): { pattern: string; file?: URI } | undefined => { @@ -322,12 +307,12 @@ export class ComputeAutomaticInstructions { if (parsedFile) { entries.push(''); if (parsedFile.header) { - const { description, applyTo, paths } = parsedFile.header; + const { description } = parsedFile.header; if (description) { entries.push(`${description}`); } entries.push(`${filePath(uri)}`); - const applyToPattern = this._getApplyToPattern(applyTo, paths); + const applyToPattern = evaluateApplyToPattern(parsedFile.header, isInClaudeRulesFolder(uri)); if (applyToPattern) { entries.push(`${applyToPattern}`); } @@ -533,3 +518,14 @@ export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined): st } return uri.toString(); } + +/** + * Returns `applyTo` or `paths` attributes based on whether the instruction file is a Claude rules file or a regular instruction file + */ +export function evaluateApplyToPattern(header: PromptHeader | undefined, isClaudeRules: boolean): string | undefined { + if (isClaudeRules) { + // For Claude rules files, `paths` is the primary attribute (defaulting to '**' when omitted) + return header?.paths?.join(', ') ?? '**'; + } + return header?.applyTo ?? undefined; // For regular instruction files, only show `applyTo` patterns, and skip if it's omitted +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 6e360f9d8b3..bc63b772fe5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -4,12 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { posix } from '../../../../../../base/common/path.js'; +import { basename, dirname } from '../../../../../../base/common/resources.js'; import { PromptsType } from '../promptTypes.js'; import { PromptsStorage } from '../service/promptsService.js'; -const { basename, dirname } = posix; - /** * File extension for the reusable prompt files. */ @@ -35,6 +33,11 @@ export const AGENT_FILE_EXTENSION = '.agent.md'; */ export const SKILL_FILENAME = 'SKILL.md'; +/** + * Regex for valid skill names: lowercase alphanumeric and hyphens only. + */ +export const VALID_SKILL_NAME_REGEX = /^[a-z0-9-]+$/; + /** * AGENT file name */ @@ -217,7 +220,7 @@ export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ * Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders). */ function isInAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER) || isInCopilotAgentsFolder(fileUri); } @@ -225,7 +228,7 @@ function isInAgentsFolder(fileUri: URI): boolean { * Helper function to check if a file is directly in the .claude/agents/ folder. */ export function isInClaudeAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); } @@ -233,7 +236,7 @@ export function isInClaudeAgentsFolder(fileUri: URI): boolean { * Helper function to check if a file is directly in the ~/.copilot/agents/ folder. */ export function isInCopilotAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith(COPILOT_USER_AGENTS_SOURCE_FOLDER.substring(1)); } @@ -255,7 +258,7 @@ export function isInClaudeRulesFolder(fileUri: URI): boolean { * PromptsType.hook regardless of its location. */ export function getPromptFileType(fileUri: URI): PromptsType | undefined { - const filename = basename(fileUri.path); + const filename = basename(fileUri); if (filename.endsWith(PROMPT_FILE_EXTENSION)) { return PromptsType.prompt; @@ -335,12 +338,15 @@ export function getPromptFileDefaultLocations(type: PromptsType): readonly IProm } } +export function getSkillFolderName(fileUri: URI): string { + return basename(dirname(fileUri)); +} /** * Gets clean prompt name without file extension. */ export function getCleanPromptName(fileUri: URI): string { - const fileName = basename(fileUri.path); + const fileName = basename(fileUri); const extensions = [ PROMPT_FILE_EXTENSION, @@ -351,33 +357,33 @@ export function getCleanPromptName(fileUri: URI): string { for (const ext of extensions) { if (fileName.endsWith(ext)) { - return basename(fileUri.path, ext); + return basename(fileUri, ext); } } if (fileName === COPILOT_CUSTOM_INSTRUCTIONS_FILENAME) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For SKILL.md files (case insensitive), return 'SKILL' if (fileName.toLowerCase() === SKILL_FILENAME.toLowerCase()) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For .md files in .github/agents/ folder, treat them as agent files // Exclude README.md to allow documentation files if (fileName.endsWith('.md') && fileName !== 'README.md' && isInAgentsFolder(fileUri)) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For .md files in .claude/rules/ folder, treat them as instruction files if (fileName.endsWith('.md') && fileName !== 'README.md' && isInClaudeRulesFolder(fileUri)) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // because we now rely on the `prompt` language ID that can be explicitly // set for any document in the editor, any file can be a "prompt" file, so // to account for that, we return the full file name including the file // extension for all other cases - return basename(fileUri.path); + return basename(fileUri); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 664dab89291..30cad1ba8fe 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -24,7 +24,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IPromptsService } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION, VALID_SKILL_NAME_REGEX } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { dirname } from '../../../../../../base/common/resources.js'; @@ -52,7 +52,7 @@ export class PromptValidator { await this.validateHeader(promptAST, promptType, target, report); await this.validateBody(promptAST, target, report); await this.validateFileName(promptAST, promptType, report); - await this.validateSkillFolderName(promptAST, promptType, report); + await this.validateSkillAttributes(promptAST, promptType, report); } private async validateFileName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { @@ -66,32 +66,55 @@ export class PromptValidator { } } - private async validateSkillFolderName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + private async validateSkillAttributes(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { if (promptType !== PromptsType.skill) { return; } const nameAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.name); - if (!nameAttribute || nameAttribute.value.type !== 'scalar') { + if (!nameAttribute) { + report(toMarker( + localize('promptValidator.skillNameMissing', "Skill must provide a name."), + new Range(1, 1, 1, 4), + MarkerSeverity.Error + )); return; } - const skillName = nameAttribute.value.value.trim(); - if (!skillName) { + const descriptionAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.description); + if (!descriptionAttribute) { + report(toMarker( + localize('promptValidator.skillDescriptionMissing', "Skill must provide a description."), + new Range(1, 1, 1, 4), + MarkerSeverity.Error + )); return; } - // Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill) - const pathParts = promptAST.uri.path.split('/'); - const skillIndex = pathParts.findIndex(part => part === 'SKILL.md'); - if (skillIndex > 0) { - const folderName = pathParts[skillIndex - 1]; - if (folderName && skillName !== folderName) { - report(toMarker( - localize('promptValidator.skillNameFolderMismatch', "The skill name '{0}' should match the folder name '{1}'.", skillName, folderName), - nameAttribute.value.range, - MarkerSeverity.Warning - )); + if (nameAttribute.value.type === 'scalar') { + const skillName = nameAttribute.value.value.trim(); + if (skillName.length > 0) { + if (!VALID_SKILL_NAME_REGEX.test(skillName)) { + report(toMarker( + localize('promptValidator.skillNameInvalidChars', "Skill name may only contain lowercase letters, numbers, and hyphens."), + nameAttribute.value.range, + MarkerSeverity.Error + )); + } + + // Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill) + const pathParts = promptAST.uri.path.split('/'); + const skillIndex = pathParts.findIndex(part => part === 'SKILL.md'); + if (skillIndex > 0) { + const folderName = pathParts[skillIndex - 1]; + if (folderName && skillName !== folderName) { + report(toMarker( + localize('promptValidator.skillNameFolderMismatch', "The skill name '{0}' should match the folder name '{1}'.", skillName, folderName), + nameAttribute.value.range, + MarkerSeverity.Warning + )); + } + } } } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index c419bb45c52..3a216261ded 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -29,7 +29,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; -import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, GITHUB_CONFIG_FOLDER, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, getSkillFolderName, GITHUB_CONFIG_FOLDER, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IWorkspaceInstructionFile, @@ -267,11 +267,23 @@ export class PromptsService extends Disposable implements IPromptsService { this._register(autorun(reader => { const plugins = this.agentPluginService.plugins.read(reader); + const hookFiles: IPluginPromptPath[] = []; for (const plugin of plugins) { if (isContributionEnabled(plugin.enablement.read(reader))) { - plugin.hooks.read(reader); + for (const hook of plugin.hooks.read(reader)) { + hookFiles.push({ + uri: hook.uri, + storage: PromptsStorage.plugin, + type: PromptsType.hook, + name: getCanonicalPluginCommandId(plugin, hook.originalId), + pluginUri: plugin.uri, + }); + } } } + + this._pluginPromptFilesByType.set(PromptsType.hook, hookFiles); + this.cachedFileLocations[PromptsType.hook] = undefined; this._onDidPluginHooksChange.fire(); })); } @@ -1047,8 +1059,7 @@ export class PromptsService extends Disposable implements IPromptsService { const sanitizedName = this.truncateAgentSkillName(name, uri); // Validate that the sanitized name matches the parent folder name (per agentskills.io specification) - const skillFolderUri = dirname(uri); - const folderName = basename(skillFolderUri); + const folderName = getSkillFolderName(uri); if (sanitizedName !== folderName) { this.logger.error(`[validateAndSanitizeSkillFile] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); throw new SkillNameMismatchError(uri, sanitizedName, folderName); @@ -1381,7 +1392,8 @@ export class PromptsService extends Disposable implements IPromptsService { } const { files } = await this.computeSkillDiscoveryInfo(token); - return { type: PromptsType.skill, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill); + return { type: PromptsType.skill, files, sourceFolders }; } /** @@ -1452,20 +1464,18 @@ export class PromptsService extends Disposable implements IPromptsService { try { const parsedFile = await this.parseNew(uri, token); - const name = parsedFile.header?.name; - if (!name) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill file missing name attribute: ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'missing-name', extensionId, source }); - continue; - } + const folderName = getSkillFolderName(uri); + let name = parsedFile.header?.name; + + if (!name) { + this.logger.warn(`[computeSkillDiscoveryInfo] Agent skill file missing name attribute, using folder name "${folderName}": ${uri}`); + name = folderName; + } const sanitizedName = this.truncateAgentSkillName(name, uri); - const skillFolderUri = dirname(uri); - const folderName = basename(skillFolderUri); if (sanitizedName !== folderName) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'name-mismatch', name: sanitizedName, extensionId, source }); - continue; + this.logger.warn(`[computeSkillDiscoveryInfo] Agent skill name "${sanitizedName}" does not match folder name "${folderName}", using folder name: ${uri}`); + } if (seenNames.has(sanitizedName)) { @@ -1475,11 +1485,6 @@ export class PromptsService extends Disposable implements IPromptsService { } const description = parsedFile.header?.description; - if (!description) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill file missing description attribute: ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'missing-description', name: sanitizedName, extensionId, source }); - continue; - } seenNames.add(sanitizedName); nameToUri.set(sanitizedName, uri); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index cd558dccf5b..12daaa059da 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -21,7 +21,7 @@ import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; +import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js'; import { HookType } from '../../promptSyntax/hookTypes.js'; @@ -67,6 +67,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { /** Hack to port data between prepare/invoke */ private readonly _resolvedModels = new Map(); + /** Tracks the current subagent nesting depth per session to detect and limit recursion. */ + private readonly _sessionDepth = new Map(); + constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, @@ -80,7 +83,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { @IProductService private readonly productService: IProductService, ) { super(); - this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents)); + this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e => + e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents) + ); } getToolData(): IToolData { @@ -210,8 +215,10 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Track whether we should collect markdown (after the last tool invocation) const markdownParts: string[] = []; - // Generate a stable subAgentInvocationId for routing edits to this subagent's content part - const subAgentInvocationId = invocation.callId ?? `subagent-${generateUuid()}`; + // Generate a stable subAgentInvocationId for routing edits to this subagent's content part. + // Use chatStreamToolCallId when available because that is what ChatToolInvocation.toolCallId + // uses in the renderer (see PR #302863), and the subagent grouping matches on toolCallId. + const subAgentInvocationId = invocation.chatStreamToolCallId ?? invocation.callId ?? `subagent-${generateUuid()}`; let inEdit = false; const progressCallback = (parts: IChatProgress[]) => { @@ -242,14 +249,32 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } }; - if (modeTools) { - modeTools[RunSubagentTool.Id] = false; - modeTools[ManageTodoListToolToolId] = false; - modeTools['copilot_askQuestions'] = false; + // Determine whether the subagent should be allowed to spawn its own subagents. + const maxDepth = this.configurationService.getValue(ChatConfiguration.SubagentsMaxDepth) ?? 0; + const sessionKey = invocation.context.sessionResource.toString(); + const currentDepth = this._sessionDepth.get(sessionKey) ?? 0; + const depthAllowed = currentDepth + 1 <= maxDepth; + + if (!modeTools) { + // Initialize modeTools so that we can still enforce the max depth restriction + modeTools = {}; + } + + // Only further-restrict RunSubagentTool: do not re-enable it if it was explicitly disabled. + const existingRunSubagentEnablement = modeTools[RunSubagentTool.Id]; + if (existingRunSubagentEnablement !== false) { + modeTools[RunSubagentTool.Id] = depthAllowed; // only enable the Run Subagent tool if we are under the max depth limit + } + + modeTools[ManageTodoListToolToolId] = false; + modeTools['copilot_askQuestions'] = false; + + if (maxDepth > 0) { + this.logService.debug(`RunSubagentTool: Nested subagents enabling ${modeTools[RunSubagentTool.Id]}: session ${sessionKey}, currentDepth: ${currentDepth}, maxDepth: ${maxDepth}`); } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, invocation.context.sessionResource); // agents can not call subagents + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, invocation.context.sessionResource); await computer.collect(variableSet, token); // Collect hooks from hook .json files @@ -283,7 +308,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { message: args.prompt, variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, - subAgentInvocationId: invocation.callId, + subAgentInvocationId: subAgentInvocationId, subAgentName: subAgentName, userSelectedModelId: modeModelId, modelConfiguration: modeModelId ? this.languageModelsService.getModelConfiguration(modeModelId) : undefined, @@ -301,17 +326,28 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } })); - // Invoke the agent - const result = await this.chatAgentService.invokeAgent( - defaultAgent.id, - agentRequest, - progressCallback, - [], - token - ); + // Invoke the agent, tracking nesting depth for recursion detection + this._sessionDepth.set(sessionKey, currentDepth + 1); + let result: IChatAgentResult | undefined; + try { + result = await this.chatAgentService.invokeAgent( + defaultAgent.id, + agentRequest, + progressCallback, + [], + token + ); + } finally { + const newDepth = (this._sessionDepth.get(sessionKey) ?? 1) - 1; + if (newDepth <= 0) { + this._sessionDepth.delete(sessionKey); + } else { + this._sessionDepth.set(sessionKey, newDepth); + } + } // Check for errors - if (result.errorDetails) { + if (result?.errorDetails) { return createToolSimpleTextResult(`Agent error: ${result.errorDetails.message}`); } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts index 97301e6d51b..26d62156ac4 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts @@ -55,6 +55,8 @@ const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = { export const SetArtifactsToolData: IToolData = { id: SetArtifactsToolId, + toolReferenceName: 'artifacts', + legacyToolReferenceFullNames: ['Set Session Artifacts'], displayName: localize('tool.setArtifacts.displayName', 'Set Session Artifacts'), modelDescription: 'Set the list of artifacts for the current session. Each artifact has a label and either a uri or a toolCallId+dataPartIndex reference, plus an optional type (devServer, screenshot, plan). This overwrites the entire artifact list. Use this to surface important links, screenshots, plans, drafts, or temporary markdown documents to the user. URIs must be fully qualified with a scheme (e.g. https://localhost:3000, file:///tmp/plan.md). To reference a screenshot or image from a previous tool result, use toolCallId and dataPartIndex instead of uri.', canBeReferencedInPrompt: true, diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index a17eb174f38..0728a528512 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -176,7 +176,7 @@ const languageModelToolSetsExtensionPoint = extensionsRegistry.ExtensionsRegistr type: 'string' }, icon: { - markdownDescription: localize('toolSetIcon', "An icon that represents this tool set, like `$(zap)`"), + markdownDescription: localize('toolSetIcon', "An icon that represents this tool set, like {0}", '`$(zap)`'), type: 'string' }, tools: { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 07344ef9636..765158017c4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -49,8 +49,6 @@ class MockAgentHostService extends mock() { public createSessionCalls: IAgentCreateSessionConfig[] = []; public agents = [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', requiresAuth: true }]; - override async setAuthToken(_token: string): Promise { } - override async listSessions(): Promise { return [...this._sessions.values()]; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index f5403958562..56f217f3c3a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -6,13 +6,13 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter } from '../../../browser/agentSessions/agentSessionsViewer.js'; +import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter, groupAgentSessionsByDate } from '../../../browser/agentSessions/agentSessionsViewer.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection } from '../../../browser/agentSessions/agentSessionsModel.js'; import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; -import { AgentSessionsGrouping } from '../../../browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } from '../../../browser/agentSessions/agentSessionsFilter.js'; suite('sessionDateFromNow', () => { @@ -951,6 +951,37 @@ suite('AgentSessionsDataSource', () => { assert.deepStrictEqual(result.map(s => s.label), ['vscode']); }); + + test('Other group appears after named repos and before Archived', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: 'no-repo', startTime: now }), + createMockSession({ id: 'repo-a', startTime: now - 1, metadata: { repositoryPath: '/path/alpha' } }), + createMockSession({ id: 'archived', startTime: now - 2, isArchived: true }), + createMockSession({ id: 'repo-b', startTime: now - 3, metadata: { repositoryPath: '/path/beta' } }), + createMockSession({ id: 'no-repo-2', startTime: now - 4 }), + ]; + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter())); + const result = getSectionsFromResult(dataSource.getChildren(createMockModel(sessions))); + + const labels = result.map(s => s.label); + const otherIndex = labels.indexOf('Other'); + const archivedIndex = labels.indexOf('Archived'); + + // Other must exist and contain the 2 sessions without repo info + assert.ok(otherIndex !== -1, 'Other section should be present'); + assert.strictEqual(result[otherIndex].sessions.length, 2); + + // Other must come after all named repo groups + for (let i = 0; i < otherIndex; i++) { + assert.strictEqual(result[i].section, AgentSessionSection.Repository, `section at index ${i} should be a named repository group`); + } + + // Archived must come after Other + assert.ok(archivedIndex > otherIndex, 'Archived section should come after Other'); + }); }); suite('getRepositoryName', () => { @@ -1039,6 +1070,7 @@ suite('AgentSessionsSorter', () => { isPinned: boolean; created: number; lastRequestStarted: number; + lastRequestEnded: number; }>): IAgentSession { const now = Date.now(); return { @@ -1050,7 +1082,7 @@ suite('AgentSessionsSorter', () => { icon: Codicon.terminal, timing: { created: overrides.created ?? now, - lastRequestEnded: undefined, + lastRequestEnded: overrides.lastRequestEnded, lastRequestStarted: overrides.lastRequestStarted, }, changes: undefined, @@ -1136,4 +1168,126 @@ suite('AgentSessionsSorter', () => { const sorted = [archivedPinned, regular].sort((a, b) => sorter.compare(a, b)); assert.deepStrictEqual(sorted.map(s => s.label), ['Session regular', 'Session archived-pinned']); }); + + test('sortBy Created: sorts by creation time regardless of lastRequestEnded', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Created); + const olderCreated = createSession({ id: 'older', created: 1000, lastRequestEnded: 5000 }); + const newerCreated = createSession({ id: 'newer', created: 3000, lastRequestEnded: 2000 }); + + const sorted = [olderCreated, newerCreated].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session newer', 'Session older']); + }); + + test('sortBy Updated: sorts by lastRequestEnded', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Updated); + const recentlyUpdated = createSession({ id: 'updated', created: 1000, lastRequestEnded: 5000 }); + const recentlyCreated = createSession({ id: 'created', created: 3000, lastRequestEnded: 2000 }); + + const sorted = [recentlyCreated, recentlyUpdated].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session updated', 'Session created']); + }); + + test('sortBy Updated: falls back to created when lastRequestEnded is undefined', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Updated); + const withRequest = createSession({ id: 'with-request', created: 1000, lastRequestEnded: 3000 }); + const withoutRequest = createSession({ id: 'no-request', created: 4000 }); + + const sorted = [withRequest, withoutRequest].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session no-request', 'Session with-request']); + }); +}); + +suite('groupAgentSessionsByDate with sortBy', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(overrides: Partial<{ + id: string; + isArchived: boolean; + isPinned: boolean; + created: number; + lastRequestEnded: number; + }>): IAgentSession { + return { + providerType: 'test', + providerLabel: 'Test', + resource: URI.parse(`test://session/${overrides.id ?? 'default'}`), + status: ChatSessionStatus.Completed, + label: `Session ${overrides.id ?? 'default'}`, + icon: Codicon.terminal, + timing: { + created: overrides.created ?? Date.now(), + lastRequestEnded: overrides.lastRequestEnded, + lastRequestStarted: undefined, + }, + changes: undefined, + metadata: undefined, + isArchived: () => overrides.isArchived ?? false, + setArchived: () => { }, + isPinned: () => overrides.isPinned ?? false, + setPinned: () => { }, + isRead: () => true, + isMarkedUnread: () => false, + setRead: () => { }, + }; + } + + test('default (Created): buckets by created time', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldSession = createSession({ id: 'old', created: tenDaysAgo, lastRequestEnded: now }); + + const grouped = groupAgentSessionsByDate([oldSession]); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 0); + assert.deepStrictEqual(olderSessions.length, 1); + }); + + test('Updated: session created long ago but recently updated goes into Today', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldButUpdated = createSession({ id: 'old-updated', created: tenDaysAgo, lastRequestEnded: now }); + + const grouped = groupAgentSessionsByDate([oldButUpdated], AgentSessionsSorting.Updated); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 1); + assert.deepStrictEqual(olderSessions.length, 0); + }); + + test('Updated: falls back to created when lastRequestEnded is undefined', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldNoUpdate = createSession({ id: 'old-no-update', created: tenDaysAgo }); + + const grouped = groupAgentSessionsByDate([oldNoUpdate], AgentSessionsSorting.Updated); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 0); + assert.deepStrictEqual(olderSessions.length, 1); + }); + + test('Updated: pinned and archived sessions are not affected by sortBy', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const pinnedOld = createSession({ id: 'pinned', created: tenDaysAgo, lastRequestEnded: now, isPinned: true }); + const archivedOld = createSession({ id: 'archived', created: tenDaysAgo, lastRequestEnded: now, isArchived: true }); + + const grouped = groupAgentSessionsByDate([pinnedOld, archivedOld], AgentSessionsSorting.Updated); + const pinnedSessions = grouped.get(AgentSessionSection.Pinned)!.sessions; + const archivedSessions = grouped.get(AgentSessionSection.Archived)!.sessions; + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + + assert.deepStrictEqual(pinnedSessions.length, 1); + assert.deepStrictEqual(archivedSessions.length, 1); + assert.deepStrictEqual(todaySessions.length, 0); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts index 95988b49868..55f7cb956f7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts @@ -7,9 +7,9 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { applyStorageSourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { applyStorageSourceFilter, BUILTIN_STORAGE, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; -function item(path: string, storage: PromptsStorage): { uri: URI; storage: PromptsStorage } { +function item(path: string, storage: PromptsStorage | string): { uri: URI; storage: string } { return { uri: URI.file(path), storage }; } @@ -218,6 +218,36 @@ suite('applyStorageSourceFilter', () => { }; assert.strictEqual(applyStorageSourceFilter(items, filter).length, 4); }); + + test('core-like filter with builtin: extension items pass when both extension and builtin are in sources', () => { + // Items from the chat extension have storage=extension but groupKey=builtin. + // The filter operates on storage, so extension items pass through regardless of groupKey. + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/e/builtin-agent.md', PromptsStorage.extension), + item('/e/third-party.md', PromptsStorage.extension), + item('/b/sessions-builtin.md', BUILTIN_STORAGE), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.extension, BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 4); + }); + + test('builtin source is respected independently', () => { + const items = [ + item('/e/from-extension.md', PromptsStorage.extension), + item('/b/from-sessions.md', BUILTIN_STORAGE), + ]; + // Only builtin in sources — extension items excluded + const filter: IStorageSourceFilter = { + sources: [BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].storage, BUILTIN_STORAGE); + }); }); suite('type safety', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index 91dbe03786d..3e94219d5ae 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -445,5 +445,72 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(deleted.length, 1); assert.ok(deleted[0].includes('github.com/owner/repo')); }); + + test('skips deletion when another installed plugin shares the same cleanup target', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/a' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + // Another plugin from the same repo still installed + [{ kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/b' }], + ); + + assert.strictEqual(deleted.length, 0); + }); + + test('proceeds with deletion when no other plugin shares the cleanup target', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/a' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + // Only unrelated plugins remain + [{ kind: PluginSourceKind.GitHub, repo: 'other-owner/other-repo' }], + ); + + assert.ok(deleted.length >= 1); + assert.ok(deleted[0].includes('github.com/owner/repo')); + }); + + test('proceeds with deletion when otherInstalledDescriptors is empty', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + [], + ); + + assert.ok(deleted.length >= 1); + assert.ok(deleted[0].includes('github.com/owner/repo')); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 74617590be8..b16251caf33 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -2165,7 +2165,7 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The skill name 'different-name' should match the folder name 'my-skill'.`); }); - test('skill without name attribute does not error', async () => { + test('skill without name attribute should error', async () => { const content = [ '---', 'description: Test Skill', @@ -2173,10 +2173,12 @@ suite('PromptValidator', () => { 'This is a skill without a name.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - assert.deepStrictEqual(markers, [], 'Expected no validation issues when name is missing'); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Skill must provide a name.`); }); - test('skill with empty name does not validate folder match', async () => { + test('skill with empty name should error', async () => { const content = [ '---', 'name: ""', @@ -2185,9 +2187,49 @@ suite('PromptValidator', () => { 'This is a skill.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - // Should get error for empty name, but no folder mismatch warning since name is empty - assert.ok(markers.some(m => m.message.includes('must not be empty')), 'Expected error for empty name'); - assert.ok(!markers.some(m => m.message.includes('should match the folder name')), 'Should not warn about folder mismatch for empty name'); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'name' attribute must not be empty.`); + }); + + test('skill without description attribute should error', async () => { + const content = [ + '---', + 'name: my-skill', + '---', + 'This is a skill without a description.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Skill must provide a description.`); + }); + + test('skill with empty description should error', async () => { + const content = [ + '---', + 'name: my-skill', + 'description: ""', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'description' attribute should not be empty.`); + }); + + + test('skill name with invalid characters should error', async () => { + const content = [ + '---', + 'name: My Skill', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.ok(markers.some(m => m.severity === MarkerSeverity.Error && m.message === 'Skill name may only contain lowercase letters, numbers, and hyphens.')); }); test('skill name with whitespace trimmed matches folder name', async () => { @@ -2240,7 +2282,7 @@ suite('PromptValidator', () => { 'This is a skill.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my_special-skill.v2/SKILL.md')); - assert.deepStrictEqual(markers, [], 'Expected no issues when name with special chars matches folder'); + assert.ok(markers.some(m => m.severity === MarkerSeverity.Error && m.message === 'Skill name may only contain lowercase letters, numbers, and hyphens.'), 'Expected error for invalid characters in skill name'); }); test('skill with non-string name type does not validate folder match', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts index 42226127e20..fabf66ac855 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts @@ -12,10 +12,11 @@ import { RenameProvider, WorkspaceEdit, Rejection } from '../../../../../../edit import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IBulkEditService, IBulkEditResult } from '../../../../../../editor/browser/services/bulkEditService.js'; -import { RenameTool, RenameToolId } from '../../../browser/tools/renameTool.js'; +import { RenameTool } from '../../../browser/tools/renameTool.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -100,9 +101,14 @@ suite('RenameTool', () => { const noopCountTokens = async () => 0; const noopProgress: ToolProgress = { report() { } }; + function createMockLanguageService(): ILanguageService { + return { getLanguageName: (id: string) => id } as unknown as ILanguageService; + } + function createTool(textModelService: ITextModelService, options?: { bulkEditService?: IBulkEditService }): RenameTool { return new RenameTool( langFeatures, + createMockLanguageService(), textModelService, createMockWorkspaceService(), createMockChatService(), @@ -124,9 +130,7 @@ suite('RenameTool', () => { test('reports no providers when none registered', () => { const tool = disposables.add(createTool(createMockTextModelService(null!))); - const data = tool.getToolData(); - assert.strictEqual(data.id, RenameToolId); - assert.ok(data.modelDescription.includes('No languages currently have rename providers')); + assert.strictEqual(tool.getToolData(), undefined); }); test('lists registered language ids', () => { @@ -136,7 +140,7 @@ suite('RenameTool', () => { provideRenameEdits: () => ({ edits: [] }), })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('typescript')); + assert.ok(data?.modelDescription.includes('typescript')); }); test('reports all languages for wildcard', () => { @@ -145,7 +149,7 @@ suite('RenameTool', () => { provideRenameEdits: () => ({ edits: [] }), })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('all languages')); + assert.ok(data?.modelDescription.includes('all languages')); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts index e0e20ec03f6..28c14f3b1ad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts @@ -13,10 +13,11 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js'; -import { UsagesTool, UsagesToolId } from '../../../browser/tools/usagesTool.js'; +import { UsagesTool } from '../../../browser/tools/usagesTool.js'; import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -91,8 +92,12 @@ suite('UsagesTool', () => { const noopCountTokens = async () => 0; const noopProgress: ToolProgress = { report() { } }; + function createMockLanguageService(): ILanguageService { + return { getLanguageName: (id: string) => id } as unknown as ILanguageService; + } + function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool { - return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); + return new UsagesTool(langFeatures, createMockLanguageService(), options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); } setup(() => { @@ -109,9 +114,7 @@ suite('UsagesTool', () => { test('reports no providers when none registered', () => { const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); - const data = tool.getToolData(); - assert.strictEqual(data.id, UsagesToolId); - assert.ok(data.modelDescription.includes('No languages currently have reference providers')); + assert.strictEqual(tool.getToolData(), undefined); }); test('lists registered language ids', () => { @@ -119,14 +122,14 @@ suite('UsagesTool', () => { const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('typescript')); + assert.ok(data?.modelDescription.includes('typescript')); }); test('reports all languages for wildcard', () => { const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); disposables.add(langFeatures.referenceProvider.register('*', { provideReferences: () => [] })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('all languages')); + assert.ok(data?.modelDescription.includes('all languages')); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 1a76f109771..4d58c6c4339 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -1239,6 +1239,30 @@ suite('ChatThinkingContentPart', () => { } as IChatToolInvocation; } + function createMockSerializedImageToolInvocation(toolId: string, invocationMessage: string, toolCallId: string): IChatToolInvocationSerialized { + return { + kind: 'toolInvocationSerialized', + toolId, + toolCallId, + invocationMessage, + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + resultDetails: { + output: { + type: 'data', + mimeType: 'image/png', + base64Data: 'AQID' + } + }, + isConfirmed: { type: 0 }, + isComplete: true, + source: ToolDataSource.Internal, + generatedTitle: undefined, + isAttachedToThinking: false, + }; + } + test('should show "Editing files" for streaming edit tools instead of generic display name', () => { const content = createThinkingPart('**Working**'); const context = createMockRenderContext(false); @@ -1364,5 +1388,44 @@ suite('ChatThinkingContentPart', () => { const labelText = button.querySelector('.icon-label')?.textContent ?? button.textContent ?? ''; assert.ok(labelText.includes('Creating newFile.ts'), `Title should contain "Creating newFile.ts" but got "${labelText}"`); }); + + test('should show external resources for serialized image tools when initially collapsed and hide them when expanded', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + const serializedImageTool = createMockSerializedImageToolInvocation( + 'chat_screenshot', 'Captured screenshot', 'image-call-1' + ); + + part.appendItem(() => { + const div = $('div.test-item'); + div.textContent = 'Image tool'; + return { domNode: div }; + }, serializedImageTool.toolId, serializedImageTool); + + const externalResources = part.domNode.querySelector('.chat-thinking-external-resources') as HTMLElement; + assert.ok(externalResources, 'Should render external resources container'); + assert.notStrictEqual(externalResources.style.display, 'none', 'Should show external resources while initially collapsed'); + + const button = part.domNode.querySelector('.monaco-button') as HTMLElement; + assert.ok(button, 'Should have expand button'); + button.click(); + + assert.strictEqual(externalResources.style.display, 'none', 'Should hide external resources when expanded'); + + button.click(); + assert.notStrictEqual(externalResources.style.display, 'none', 'Should show external resources again after collapsing'); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts index 3125d372e86..535b44dcd18 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts @@ -182,7 +182,7 @@ suite('ChatToolProgressSubPart', () => { }); test('adds shimmer styling for active MCP tool progress', () => { - const mcpTool = createSerializedToolInvocation({ + const mcpTool = createToolInvocation({ source: { type: 'mcp', label: 'Weather MCP', @@ -221,4 +221,28 @@ suite('ChatToolProgressSubPart', () => { assert.strictEqual(part.domNode.querySelector('.shimmer-progress'), null); }); + + test('does not add shimmer styling for completed MCP tool progress', () => { + const mcpTool = createSerializedToolInvocation({ + source: { + type: 'mcp', + label: 'Weather MCP', + serverLabel: 'Weather', + instructions: undefined, + collectionId: 'collection', + definitionId: 'definition' + }, + toolId: 'weather_lookup' + }); + + const part = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + mcpTool, + createRenderContext(false), + mockMarkdownRenderer, + new Set() + )); + + assert.strictEqual(part.domNode.querySelector('.shimmer-progress'), null); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 6727b376b48..c4dacb71de5 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -55,7 +55,7 @@ import { MockChatVariablesService } from '../mockChatVariables.js'; import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; import { MockLanguageModelToolsService } from '../tools/mockLanguageModelToolsService.js'; import { MockChatService } from './mockChatService.js'; -import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { MockChatSessionsService } from '../mockChatSessionsService.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; @@ -937,7 +937,7 @@ suite('ChatService', () => { assert.ok(newModel, 'New model should exist at the real resource'); assert.ok(newModel.contributedChatSession, 'New model should have contributedChatSession'); assert.deepStrictEqual( - newModel.contributedChatSession?.initialSessionOptions?.map(o => ({ optionId: o.optionId, value: o.value })), + ChatSessionOptionsMap.toStrValueArray(newModel.contributedChatSession?.initialSessionOptions), [ { optionId: 'model', value: 'claude-3.5-sonnet' }, { optionId: 'repo', value: 'my-repo' }, diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index dbf321f972e..f49d4fbbbd2 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { AsyncEmitter, Emitter } from '../../../../../base/common/event.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { ReadonlyChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint, ChatSessionOptionsMap } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -17,8 +17,9 @@ import { Target } from '../../common/promptSyntax/promptTypes.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; - private readonly _onDidChangeSessionOptions = new Emitter(); + private readonly _onDidChangeSessionOptions = new Emitter(); readonly onDidChangeSessionOptions = this._onDidChangeSessionOptions.event; + private readonly _onDidChangeItemsProviders = new Emitter<{ readonly chatSessionType: string }>(); readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event; @@ -37,15 +38,13 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onDidChangeOptionGroups = new Emitter(); readonly onDidChangeOptionGroups = this._onDidChangeOptionGroups.event; - private readonly _onRequestNotifyExtension = new AsyncEmitter(); - readonly onRequestNotifyExtension = this._onRequestNotifyExtension.event; private sessionItemControllers = new Map }>(); private contentProviders = new Map(); private contributions: IChatSessionsExtensionPoint[] = []; private optionGroups = new Map(); - private sessionOptions = new ResourceMap>(); - private inProgress = new Map(); + private sessionOptions = new ResourceMap(); + private inProgress = new Map(); // For testing: allow triggering events fireDidChangeItemsProviders(event: { chatSessionType: string }): void { @@ -126,13 +125,8 @@ export class MockChatSessionsService implements IChatSessionsService { })); } - reportInProgress(chatSessionType: string, count: number): void { - this.inProgress.set(chatSessionType, count); - this._onDidChangeInProgress.fire(); - } - - getInProgress(): { displayName: string; count: number }[] { - return Array.from(this.inProgress.entries()).map(([displayName, count]) => ({ displayName, count })); + getInProgress(): { chatSessionType: string; count: number }[] { + return Array.from(this.inProgress.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } registerChatSessionContentProvider(chatSessionType: string, provider: IChatSessionContentProvider): IDisposable { @@ -173,32 +167,38 @@ export class MockChatSessionsService implements IChatSessionsService { } } - getNewSessionOptionsForSessionType(_chatSessionType: string): Record | undefined { + getNewSessionOptionsForSessionType(_chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined { return undefined; } - setNewSessionOptionsForSessionType(_chatSessionType: string, _options: Record): void { + setNewSessionOptionsForSessionType(_chatSessionType: string, _options: ReadonlyChatSessionOptionsMap): void { // noop } - async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { - await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); - } - - getSessionOptions(sessionResource: URI): Map | undefined { + getSessionOptions(sessionResource: URI): ReadonlyChatSessionOptionsMap | undefined { const options = this.sessionOptions.get(sessionResource); return options && options.size > 0 ? options : undefined; } getSessionOption(sessionResource: URI, optionId: string): string | undefined { - return this.sessionOptions.get(sessionResource)?.get(optionId); + const value = this.sessionOptions.get(sessionResource)?.get(optionId); + return typeof value === 'string' ? value : value?.id; } setSessionOption(sessionResource: URI, optionId: string, value: string): boolean { + return this.updateSessionOptions(sessionResource, new Map([[optionId, value]])); + } + + updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean { if (!this.sessionOptions.has(sessionResource)) { this.sessionOptions.set(sessionResource, new Map()); } - this.sessionOptions.get(sessionResource)!.set(optionId, value); + for (const [optionId, value] of updates) { + this.sessionOptions.get(sessionResource)!.set(optionId, value); + } + + this._onDidChangeSessionOptions.fire({ sessionResource, updates }); + return true; } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index d30ca039c5a..8a3a7439207 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -27,7 +27,7 @@ import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, ICh import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatModel, ChatRequestModel, ChatResponseResource, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; -import { ChatRequestQueueKind, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatService, IChatTerminalToolInvocationData, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { MockChatService } from '../chatService/mockChatService.js'; @@ -553,6 +553,65 @@ suite('Response', () => { assert.strictEqual(textEditGroups.length, 0, 'Should not have textEditGroup for cell edits'); assert.strictEqual(notebookEditGroups.length, 1, 'Should have notebookEditGroup for cell edits'); }); + + test('external terminal tool updates preserve toolSpecificData when completing an existing invocation', () => { + const response = store.add(new Response([])); + const toolSpecificData: IChatTerminalToolInvocationData = { + kind: 'terminal', + language: 'bash', + commandLine: { original: 'npm test' }, + terminalCommandOutput: { text: 'all green' }, + terminalCommandState: { exitCode: 0 }, + }; + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-1', + toolName: 'run_in_terminal', + isComplete: false, + invocationMessage: 'Running npm test', + }); + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-1', + toolName: 'run_in_terminal', + isComplete: true, + pastTenseMessage: 'Ran npm test', + toolSpecificData, + }); + + assert.strictEqual(response.value.length, 1); + assert.strictEqual(response.value[0].kind, 'toolInvocation'); + assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData); + assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true); + }); + + test('external terminal tool updates preserve toolSpecificData when first pushed as complete', () => { + const response = store.add(new Response([])); + const toolSpecificData: IChatTerminalToolInvocationData = { + kind: 'terminal', + language: 'bash', + commandLine: { original: 'npm test' }, + terminalCommandOutput: { text: 'all green' }, + terminalCommandState: { exitCode: 0 }, + }; + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-2', + toolName: 'run_in_terminal', + isComplete: true, + invocationMessage: 'Running npm test', + pastTenseMessage: 'Ran npm test', + toolSpecificData, + }); + + assert.strictEqual(response.value.length, 1); + assert.strictEqual(response.value[0].kind, 'toolInvocation'); + assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData); + assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true); + }); }); suite('normalizeSerializableChatData', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 5e43f1377df..d66d3e7d785 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -271,12 +271,17 @@ suite('parsePluginSource', () => { test('parses github object source', () => { const result = parsePluginSource({ source: 'github', repo: 'owner/repo' }, undefined, logContext); - assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined }); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined, path: undefined }); }); test('parses github object source with ref and sha', () => { const result = parsePluginSource({ source: 'github', repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }, undefined, logContext); - assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', path: undefined }); + }); + + test('parses github object source with path', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo', path: 'plugins/my-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined, path: 'plugins/my-plugin' }); }); test('returns undefined for github source missing repo', () => { @@ -291,6 +296,10 @@ suite('parsePluginSource', () => { assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', sha: 'abc123' }, undefined, logContext), undefined); }); + test('returns undefined for github source with non-string path', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', path: 42 } as never, undefined, logContext), undefined); + }); + test('parses url object source', () => { const result = parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin.git' }, undefined, logContext); assert.deepStrictEqual(result, { kind: PluginSourceKind.GitUrl, url: 'https://gitlab.com/team/plugin.git', ref: undefined, sha: undefined }); @@ -364,6 +373,10 @@ suite('getPluginSourceLabel', () => { assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo' }), 'owner/repo'); }); + test('formats github source with path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/foo' }), 'owner/repo/plugins/foo'); + }); + test('formats url source', () => { assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }), 'https://example.com/repo.git'); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index d8d5f2b33ab..f78133dcdba 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -1456,6 +1456,86 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`/home/user/.claude/skills/claude-personal/SKILL.md`)); assert.equal(xmlContents(skills[1], 'name')[0], 'claude-personal'); }); + + test('should include skills with missing name, missing description, or mismatched folder name', async () => { + const rootFolderName = 'skills-missing-metadata-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + await mockFiles(fileService, [ + { + // Skill with no name attribute - should use folder name as fallback + path: `${rootFolder}/.claude/skills/no-name-skill/SKILL.md`, + contents: [ + '---', + 'description: \'A skill without a name\'', + '---', + 'Skill content without name', + ] + }, + { + // Skill with no description attribute - should still be included + path: `${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`, + contents: [ + '---', + 'name: \'no-desc-skill\'', + '---', + 'Skill content without description', + ] + }, + { + // Skill where name does not match folder name - should still be included + path: `${rootFolder}/.claude/skills/actual-folder/SKILL.md`, + contents: [ + '---', + 'name: \'mismatched-name\'', + 'description: \'A skill with mismatched name\'', + '---', + 'Skill content with mismatched name', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_readFile': true }, // Enable readFile tool + undefined, + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for skills list'); + + const skillsList = xmlContents(textVariables[0].value, 'skills'); + assert.equal(skillsList.length, 1, 'There should be one skills list'); + + const skills = xmlContents(skillsList[0], 'skill'); + assert.equal(skills.length, 3, 'All three skills should be included despite missing/mismatched metadata'); + + // Skill with missing name should use folder name as fallback + assert.equal(xmlContents(skills[0], 'name')[0], 'no-name-skill'); + assert.equal(xmlContents(skills[0], 'description')[0], 'A skill without a name'); + assert.equal(xmlContents(skills[0], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-name-skill/SKILL.md`)); + + // Skill with missing description should still be listed + assert.equal(xmlContents(skills[1], 'name')[0], 'no-desc-skill'); + assert.equal(xmlContents(skills[1], 'description').length, 0, 'Should have no description element'); + assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`)); + + // Skill with mismatched name should use folder name + assert.equal(xmlContents(skills[2], 'name')[0], 'mismatched-name'); + assert.equal(xmlContents(skills[2], 'description')[0], 'A skill with mismatched name'); + assert.equal(xmlContents(skills[2], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/actual-folder/SKILL.md`)); + }); }); suite('edge cases', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 495e54702d1..1f4a09bd0d3 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -2427,11 +2427,11 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results when agent skills are enabled'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 4, 'Should find 4 skills total'); + assert.strictEqual(result.length, 5, 'Should find 5 skills total'); // Check project skills (both from .github/skills and .claude/skills) const projectSkills = result.filter(skill => skill.storage === PromptsStorage.local); - assert.strictEqual(projectSkills.length, 2, 'Should find 2 project skills'); + assert.strictEqual(projectSkills.length, 3, 'Should find 3 project skills'); const githubSkill1 = projectSkills.find(skill => skill.name === 'GitHub Skill 1'); assert.ok(githubSkill1, 'Should find GitHub skill 1'); @@ -2443,6 +2443,12 @@ suite('PromptsService', () => { assert.strictEqual(claudeSkill1.description, 'A Claude skill for testing'); assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/Claude Skill 1/SKILL.md`); + // The invalid-skill (no name attribute) should now use folder name as fallback + const invalidSkill = projectSkills.find(skill => skill.name === 'invalid-skill'); + assert.ok(invalidSkill, 'Should find invalid-skill using folder name as fallback'); + assert.strictEqual(invalidSkill.description, 'Invalid skill, no name'); + assert.strictEqual(invalidSkill.uri.path, `${rootFolder}/.claude/skills/invalid-skill/SKILL.md`); + // Check personal skills const personalSkills = result.filter(skill => skill.storage === PromptsStorage.user); assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); @@ -2494,12 +2500,18 @@ suite('PromptsService', () => { const allResult = await service.findAgentSkills(CancellationToken.None); - // Should still return the valid skill, even if one has parsing errors + // Should return both skills - the malformed one uses folder name as fallback assert.ok(allResult, 'Should return results even with parsing errors'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find 1 valid skill'); - assert.strictEqual(result[0].name, 'Valid Skill'); - assert.strictEqual(result[0].storage, PromptsStorage.local); + assert.strictEqual(result.length, 2, 'Should find 2 skills'); + + const validSkill = result.find(s => s.name === 'Valid Skill'); + assert.ok(validSkill, 'Should find the valid skill'); + assert.strictEqual(validSkill.storage, PromptsStorage.local); + + const invalidSkill = result.find(s => s.name === 'invalid-skill'); + assert.ok(invalidSkill, 'Should find skill with folder name as fallback despite malformed YAML'); + assert.strictEqual(invalidSkill.storage, PromptsStorage.local); }); test('should return empty array when no skills found', async () => { @@ -2736,7 +2748,7 @@ suite('PromptsService', () => { assert.strictEqual(result[0].storage, PromptsStorage.local); }); - test('should skip skills where name does not match folder name', async () => { + test('should include skills where name does not match folder name using folder name as fallback', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); @@ -2753,7 +2765,7 @@ suite('PromptsService', () => { contents: [ '---', 'name: "Correct Skill Name"', - 'description: "This skill should be skipped due to name mismatch"', + 'description: "This skill should use folder name as fallback"', '---', 'Skill content', ], @@ -2775,11 +2787,17 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find only 1 skill (mismatched one skipped)'); - assert.strictEqual(result[0].name, 'Valid Skill', 'Should only find the valid skill'); + assert.strictEqual(result.length, 2, 'Should find both skills'); + + const mismatchedSkill = result.find(s => s.name === 'Correct Skill Name'); + assert.ok(mismatchedSkill, 'Should find skill with folder name as fallback'); + assert.strictEqual(mismatchedSkill.description, 'This skill should use folder name as fallback'); + + const validSkill = result.find(s => s.name === 'Valid Skill'); + assert.ok(validSkill, 'Should find the valid skill'); }); - test('should skip skills with missing name attribute', async () => { + test('should include skills with missing name attribute using folder name as fallback', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); @@ -2815,8 +2833,14 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find only 1 skill (one without name skipped)'); - assert.strictEqual(result[0].name, 'Valid Named Skill', 'Should only find skill with name attribute'); + assert.strictEqual(result.length, 2, 'Should find both skills'); + + const noNameSkill = result.find(s => s.name === 'no-name-skill'); + assert.ok(noNameSkill, 'Should find skill with folder name as fallback'); + assert.strictEqual(noNameSkill.description, 'This skill has no name attribute'); + + const validSkill = result.find(s => s.name === 'Valid Named Skill'); + assert.ok(validSkill, 'Should find skill with name attribute'); }); test('should include extension-provided skills in findAgentSkills', async () => { @@ -3763,6 +3787,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo from-plugin' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); @@ -3784,6 +3809,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo before' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); @@ -3796,6 +3822,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo after' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }], undefined); const after = await service.getHooks(CancellationToken.None); @@ -3848,6 +3875,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo from-plugin' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 79d99695eb5..61035aeab15 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -11,8 +11,8 @@ import { NullLogService } from '../../../../../../../platform/log/common/log.js' import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; -import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; -import { IChatService } from '../../../../common/chatService/chatService.js'; +import { IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService, UserSelectedTools } from '../../../../common/participants/chatAgents.js'; +import { IChatProgress, IChatService } from '../../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; @@ -20,6 +20,9 @@ import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/se import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; +import { IToolInvocation, ToolProgress } from '../../../../common/tools/languageModelToolsService.js'; +import { IChatModel } from '../../../../common/model/chatModel.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -491,4 +494,142 @@ suite('RunSubagentTool', () => { }); }); }); + + suite('nested subagent depth tracking', () => { + /** + * Creates a RunSubagentTool with mocked services suitable for invoke() testing. + * The returned `capturedRequests` array collects every IChatAgentRequest passed to invokeAgent. + */ + let callIdCounter = 0; + function createInvokableTool(opts: { + maxDepth: number; + capturedRequests: IChatAgentRequest[]; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService({ + [ChatConfiguration.SubagentsMaxDepth]: opts.maxDepth, + }); + const promptsService = new MockPromptsService(); + + const mockChatAgentService: Pick = { + getDefaultAgent() { + return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; + }, + async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { + opts.capturedRequests.push(request); + return {}; + }, + }; + + const mockChatService: Pick = { + getSession() { + return { + getRequests: () => [{ id: 'req-1' }], + acceptResponseProgress: () => { }, + } as unknown as IChatModel; + }, + }; + + const mockInstantiationService: Pick = { + createInstance(..._args: never[]): { collect: () => Promise } { + return { collect: async () => { } }; + }, + }; + + const tool = testDisposables.add(new RunSubagentTool( + mockChatAgentService as IChatAgentService, + mockChatService as IChatService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + promptsService, + mockInstantiationService as IInstantiationService, + {} as IProductService, + )); + + return { tool, mockChatAgentService }; + } + + function createInvocation(sessionUri: URI, userSelectedTools?: UserSelectedTools): IToolInvocation { + return { + callId: `call-${++callIdCounter}`, + toolId: 'runSubagent', + parameters: { prompt: 'do something', description: 'test' }, + context: { sessionResource: sessionUri }, + userSelectedTools: userSelectedTools ?? { runSubagent: true }, + } as IToolInvocation; + } + + const countTokens = async () => 0; + const noProgress: ToolProgress = { report() { } }; + + test('disables runSubagent tool when maxDepth is 0', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 0, capturedRequests }); + const sessionUri = URI.parse('test://session/depth0'); + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 1); + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], false); + }); + + test('enables runSubagent tool at depth 0 when maxDepth >= 1', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 3, capturedRequests }); + const sessionUri = URI.parse('test://session/depth-enabled'); + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 1); + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + }); + + test('disables runSubagent tool when depth reaches maxDepth', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const sessionUri = URI.parse('test://session/depth-limit'); + + // maxDepth=1, so the first invoke (depth 0→1) should allow nesting, + // but the second invoke (depth 1→2) should not since 1+1 <= 1 is false. + const { tool, mockChatAgentService } = createInvokableTool({ maxDepth: 1, capturedRequests }); + + // Simulate nested invocation: the first invoke's invokeAgent callback + // triggers a second invoke on the same tool (same session). + capturedRequests.length = 0; + mockChatAgentService.invokeAgent = async (_id: string, request: IChatAgentRequest) => { + capturedRequests.push(request); + // On the first call (depth 0), simulate a nested subagent call + if (capturedRequests.length === 1) { + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + } + return {}; + }; + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 2); + // First call at depth 0: should enable (0 + 1 <= 1) + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + // Second call at depth 1: should disable (1 + 1 <= 1 is false) + assert.strictEqual(capturedRequests[1].userSelectedTools?.['runSubagent'], false); + }); + + test('depth is decremented after invoke completes', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 2, capturedRequests }); + const sessionUri = URI.parse('test://session/depth-decrement'); + + // First invoke + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + // Second invoke on same session should start at depth 0 again + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 2); + // Both should have runSubagent enabled since depth resets after each invoke + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + assert.strictEqual(capturedRequests[1].userSelectedTools?.['runSubagent'], true); + }); + }); }); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 2a2ae1cc5d6..55fb2fdf16f 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -212,6 +212,8 @@ class TrackedDocumentInfo extends Disposable { trigger: EditTelemetryTrigger; languageId: string; statsUuid: string; + conversationId: string | undefined; + requestId: string | undefined; modifiedCount: number; deltaModifiedCount: number; totalModifiedCount: number; @@ -229,6 +231,8 @@ class TrackedDocumentInfo extends Disposable { languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; + conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; @@ -248,6 +252,8 @@ class TrackedDocumentInfo extends Disposable { trigger, languageId: this._doc.document.languageId.get(), statsUuid: statsUuid, + conversationId: repr.props.$$sessionId, + requestId: repr.props.$$requestId, modifiedCount: value, deltaModifiedCount: deltaModifiedCount, totalModifiedCount: data.totalModifiedCharactersInFinalState, diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index 53918c80b78..8f36c834a3a 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -459,7 +459,7 @@ configurationRegistry.registerConfiguration({ type: 'string', // expression ({ "**/*.js": { "when": "$(basename).js" } }) pattern: '\\w*\\$\\(basename\\)\\w*', default: '$(basename).ext', - description: nls.localize('explorer.autoRevealExclude.when', 'Additional check on the siblings of a matching file. Use $(basename) as variable for the matching file name.') + description: nls.localize('explorer.autoRevealExclude.when', 'Additional check on the siblings of a matching file. Use {0} as variable for the matching file name.', '$(basename)') } } } diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts index ad4e2f193d6..5c7fbd966fb 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -29,19 +29,24 @@ import { ResourceSet } from '../../../../base/common/map.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { Limiter } from '../../../../base/common/async.js'; // --- Configuration --- Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'imageCarousel', - title: localize('imageCarouselConfigurationTitle', "Image Carousel"), + title: localize('imageCarouselConfigurationTitle', "Images Preview"), type: 'object', properties: { 'imageCarousel.explorerContextMenu.enabled': { type: 'boolean', - default: false, - markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Image Carousel** option appears in the Explorer context menu. This is an experimental feature."), + default: true, + markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Images Preview** option appears in the Explorer context menu."), + tags: ['experimental'], + }, + 'imageCarousel.chat.enabled': { + type: 'boolean', + default: true, + description: localize('imageCarousel.chat.enabled', "Controls whether clicking an image attachment in chat opens the Images Preview viewer."), tags: ['experimental'], }, } @@ -53,7 +58,7 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane EditorPaneDescriptor.create( ImageCarouselEditor, ImageCarouselEditor.ID, - localize('imageCarouselEditor', "Image Carousel") + localize('imageCarouselEditor', "Images Preview") ), [ new SyncDescriptor(ImageCarouselEditorInput) @@ -112,7 +117,7 @@ class OpenImageInCarouselAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.openImageInCarousel', - title: localize2('openImageInCarousel', "Open Image in Carousel"), + title: localize2('openImageInCarousel', "Open in Images Preview"), f1: false }); } @@ -129,7 +134,7 @@ class OpenImageInCarouselAction extends Action2 { } else if (isSingleImageArgs(args)) { collection = { id: generateUuid(), - title: args.title ?? localize('imageCarousel.title', "Image Carousel"), + title: args.title ?? localize('imageCarousel.title', "Images Preview"), sections: [{ title: '', images: [{ @@ -175,33 +180,20 @@ async function collectImageFilesFromFolder(fileService: IFileService, folderUri: return imageUris; } -async function readImageFiles(fileService: IFileService, uris: URI[]): Promise { - const limiter = new Limiter(10); - const results = await Promise.all( - uris.map(uri => limiter.queue(async () => { - try { - const content = await fileService.readFile(uri); - const mimeType = getMediaMime(uri.path) ?? 'image/png'; - return { - id: generateUuid(), - name: basename(uri), - mimeType, - data: content.value, - uri, - }; - } catch { - return undefined; - } - })) - ); - return results.filter((r): r is ICarouselImage => r !== undefined); +function createImageEntries(uris: URI[]): ICarouselImage[] { + return uris.map(uri => ({ + id: generateUuid(), + name: basename(uri), + mimeType: getMediaMime(uri.path) ?? 'image/png', + uri, + })); } class OpenImagesInCarouselFromExplorerAction extends Action2 { constructor() { super({ id: 'workbench.action.openImagesInCarousel', - title: localize2('openImagesInCarousel', "Open in Image Carousel"), + title: localize2('openImagesInCarousel', "Open in Images Preview"), f1: false, menu: [{ id: MenuId.ExplorerContext, @@ -292,11 +284,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { return; } - const images = await readImageFiles(fileService, imageUris); - if (images.length === 0) { - notificationService.error(localize('imageReadError', "Could not read the selected images.")); - return; - } + const images = createImageEntries(imageUris); let startIndex = 0; if (startUri) { @@ -308,7 +296,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { const collection: IImageCarouselCollection = { id: generateUuid(), - title: localize('imageCarousel.explorerTitle', "Image Carousel"), + title: localize('imageCarousel.explorerTitle', "Images Preview"), sections: [{ title: '', images, diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts index 0b9aac33655..854f5deca26 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts @@ -12,6 +12,7 @@ import { clamp } from '../../../../base/common/numbers.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; @@ -49,6 +50,7 @@ export class ImageCarouselEditor extends EditorPane { private _flatImages: IFlatImageEntry[] = []; private readonly _contentDisposables = this._register(new DisposableStore()); private readonly _imageDisposables = this._register(new DisposableStore()); + private readonly _blobUrlCache = new Map(); private _elements: { root: HTMLElement; @@ -68,7 +70,8 @@ export class ImageCarouselEditor extends EditorPane { group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService + @IStorageService storageService: IStorageService, + @IFileService private readonly _fileService: IFileService ) { super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService); } @@ -95,6 +98,7 @@ export class ImageCarouselEditor extends EditorPane { override clearInput(): void { this._contentDisposables.clear(); this._imageDisposables.clear(); + this._revokeCachedBlobUrls(); this._zoomScale = 'fit'; if (this._container) { clearNode(this._container); @@ -114,6 +118,7 @@ export class ImageCarouselEditor extends EditorPane { this._contentDisposables.clear(); this._imageDisposables.clear(); + this._revokeCachedBlobUrls(); clearNode(this._container); if (this._flatImages.length === 0) { @@ -263,11 +268,8 @@ export class ImageCarouselEditor extends EditorPane { btn.ariaLabel = localize('imageCarousel.thumbnailLabel', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length); const img = thumbnail.img as HTMLImageElement; - const blob = new Blob([image.data.buffer.slice(0)], { type: image.mimeType }); - const url = URL.createObjectURL(blob); - img.src = url; + this._loadBlobUrl(image).then(url => { img.src = url; }); img.alt = image.name; - this._contentDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); this._contentDisposables.add(addDisposableListener(btn, 'click', () => { this._currentIndex = currentFlatIndex; @@ -290,20 +292,42 @@ export class ImageCarouselEditor extends EditorPane { * Update only the changing parts: main image src, caption, button states, thumbnail selection. * No DOM teardown/rebuild — eliminates the blank flash. */ - private updateCurrentImage(): void { + private async updateCurrentImage(): Promise { if (!this._elements) { return; } - // Swap main image blob URL - this._imageDisposables.clear(); - const entry = this._flatImages[this._currentIndex]; + // Capture the navigation index before starting async work so that + // we can discard stale results if the user navigates while loading/decoding. + const navigationIndex = this._currentIndex; + + // Swap main image using cached/lazy-loaded blob URL. + // Pre-decode via decode() before assigning to so the browser + // decodes on a worker thread, avoiding main-thread stalls during commit. + const entry = this._flatImages[navigationIndex]; const currentImage = entry.image; - const blob = new Blob([currentImage.data.buffer.slice(0)], { type: currentImage.mimeType }); - const url = URL.createObjectURL(blob); - this._elements.mainImage.src = url; - this._elements.mainImage.alt = currentImage.name; - this._imageDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); + const url = await this._loadBlobUrl(currentImage); + + // If the user navigated while loading the blob URL, discard this result. + if (this._currentIndex !== navigationIndex) { + return; + } + + const tmp = new Image(); + tmp.src = url; + tmp.decode().then(() => { + // Only apply if user hasn't navigated away during decode + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }, () => { + // Decode failed (invalid image) — still show src for browser fallback + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }); // Reset zoom when switching images this._applyZoom('fit'); @@ -324,32 +348,80 @@ export class ImageCarouselEditor extends EditorPane { this._elements.prevBtn.disabled = this._currentIndex === 0; this._elements.nextBtn.disabled = this._currentIndex === this._flatImages.length - 1; - // Update thumbnail selection + // Update thumbnail selection — only toggle active class and + // call getBoundingClientRect on the active thumbnail to avoid + // layout thrashing across all thumbnails on every navigation. for (let i = 0; i < this._thumbnailElements.length; i++) { const isActive = i === this._currentIndex; const thumbnail = this._thumbnailElements[i]; thumbnail.classList.toggle('active', isActive); if (isActive) { thumbnail.setAttribute('aria-current', 'page'); - // Scroll only the thumbnail strip, not the entire editor - const container = this._elements.sectionsContainer; - const containerRect = container.getBoundingClientRect(); - const thumbRect = thumbnail.getBoundingClientRect(); - if (thumbRect.left < containerRect.left) { - container.scrollLeft += thumbRect.left - containerRect.left; - } else if (thumbRect.right > containerRect.right) { - container.scrollLeft += thumbRect.right - containerRect.right; - } } else { thumbnail.removeAttribute('aria-current'); } } + // Scroll the active thumbnail into view without blocking the main thread. + // Using scrollIntoView with 'nearest' avoids forced layout from + // getBoundingClientRect + scrollLeft and is handled efficiently by + // the browser's scroll machinery. + const activeThumbnail = this._thumbnailElements[this._currentIndex]; + if (activeThumbnail) { + activeThumbnail.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + // Update editor title to reflect current section if (this.input instanceof ImageCarouselEditorInput) { const currentSection = this._sections[entry.sectionIndex]; this.input.setName(currentSection.title || this.input.collection.title); } + + // Preload adjacent images for smoother navigation + this._preloadAdjacentImages(); + } + + private async _loadBlobUrl(image: ICarouselImage): Promise { + const cached = this._blobUrlCache.get(image.id); + if (cached) { + return cached; + } + + let buffer: Uint8Array; + if (image.data) { + buffer = image.data.buffer; + } else if (image.uri) { + const content = await this._fileService.readFile(image.uri); + buffer = content.value.buffer; + } else { + return ''; + } + + const blob = new Blob([buffer as Uint8Array], { type: image.mimeType }); + const url = URL.createObjectURL(blob); + this._blobUrlCache.set(image.id, url); + return url; + } + + private _revokeCachedBlobUrls(): void { + for (const url of this._blobUrlCache.values()) { + URL.revokeObjectURL(url); + } + this._blobUrlCache.clear(); + } + + private _preloadAdjacentImages(): void { + for (const idx of [this._currentIndex - 1, this._currentIndex + 1]) { + if (idx >= 0 && idx < this._flatImages.length) { + this._loadBlobUrl(this._flatImages[idx].image).then(url => { + // Pre-decode via decode() so the compositor doesn't block + // the main thread decoding this image during commit. + const img = new Image(); + img.src = url; + img.decode().catch(() => { /* invalid image */ }); + }); + } + } } previous(): void { @@ -431,9 +503,14 @@ export class ImageCarouselEditor extends EditorPane { img.classList.add('scale-to-fit'); img.classList.remove('pixelated'); img.style.zoom = ''; + // Remove zoomed/overflow before scrollTo to avoid an expensive + // synchronous ScrollLayer that blocks the main thread. + const wasZoomed = container.classList.contains('zoomed'); container.classList.remove('zoomed'); container.classList.remove('zoom-out'); - container.scrollTo(0, 0); + if (wasZoomed) { + container.scrollTo(0, 0); + } } else { const scale = clamp(newScale, MIN_SCALE, MAX_SCALE); this._zoomScale = scale; diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts index cda9d2b23d8..c0aaa014ffd 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts @@ -10,7 +10,8 @@ export interface ICarouselImage { readonly id: string; readonly name: string; readonly mimeType: string; - readonly data: VSBuffer; + /** In-memory image data. Omit when the image can be loaded lazily from `uri`. */ + readonly data?: VSBuffer; readonly uri?: URI; readonly source?: string; readonly caption?: string; diff --git a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts index fc95cb68395..c78c14c5df0 100644 --- a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts +++ b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts @@ -361,7 +361,7 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { assert.strictEqual(infoMessages.length, 0, 'Should not show info notification'); }); - test('all image reads failing shows error notification', async () => { + test('images with URIs are passed lazily without reading file contents', async () => { const folderUri = URI.file('/workspace/broken'); const resolveMap = new Map(); @@ -372,8 +372,13 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { ] )); - // No file contents → all readFile calls will fail + // No file contents — with lazy loading, no readFile should be called at action time + let readFileCallCount = 0; stubFileService(resolveMap, new Map()); + instantiationService.stub(IFileService, 'readFile', async () => { + readFileCallCount++; + throw new Error('readFile should not be called'); + }); stubExplorerService([]); stubEditorService(); stubNotificationService(); @@ -384,7 +389,11 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { await instantiationService.invokeFunction(command.handler, folderUri); - assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when all reads fail'); - assert.strictEqual(errorMessages.length, 1, 'Should show error notification for read failures'); + assert.strictEqual(readFileCallCount, 0, 'readFile should not be called during action'); + assert.strictEqual(openedInputs.length, 1, 'Should open carousel with lazy image entries'); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include 2 lazy image entries'); + assert.strictEqual(images[0].data, undefined, 'Image data should not be loaded eagerly'); + assert.ok(images[0].uri, 'Image should have a URI for lazy loading'); }); }); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index e5ad9450dc1..acb788bced6 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -17,6 +17,7 @@ import { InlineChatNotebookContribution } from './inlineChatNotebook.js'; import { IWorkbenchContributionsRegistry, registerWorkbenchContribution2, Extensions as WorkbenchExtensions, WorkbenchPhase } from '../../../common/contributions.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { InlineChatEnabler, InlineChatEscapeToolContribution, InlineChatSessionServiceImpl } from './inlineChatSessionServiceImpl.js'; +import { IInlineChatHistoryService, InlineChatHistoryService } from './inlineChatHistoryService.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { CancelAction, ChatSubmitAction } from '../../chat/browser/actions/chatExecuteActions.js'; import { localize } from '../../../../nls.js'; @@ -36,6 +37,7 @@ registerAction2(InlineChatActions.RephraseInlineChatSessionAction); // --- browser registerSingleton(IInlineChatSessionService, InlineChatSessionServiceImpl, InstantiationType.Delayed); +registerSingleton(IInlineChatHistoryService, InlineChatHistoryService, InstantiationType.Delayed); // --- MENU special --- diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 9c8ba1610ba..d4bfb599f0c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -254,6 +254,12 @@ export class FixDiagnosticsAction extends AbstractInlineChatAction { group: '2_chat', order: 1, when: ContextKeyExpr.and(CTX_FIX_DIAGNOSTICS_ENABLED, EditorContextKeys.selectionHasDiagnostics, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + }, { + id: MenuId.MarkerHoverStatusBar, + group: '1_fix', + order: 1, + when: ContextKeyExpr.and(CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + precondition: null, }] }); } @@ -471,6 +477,7 @@ export class SubmitInlineChatInputAction extends AbstractInlineChatAction { override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { const value = ctrl.inputWidget.value; if (value) { + ctrl.inputWidget.addToHistory(value); ctrl.inputWidget.hide(); ctrl.run({ message: value, autoSend: true }); } @@ -591,6 +598,9 @@ export class QueueInChatAction extends AbstractInlineChatAction { } const value = ctrl.inputWidget.value; + if (value) { + ctrl.inputWidget.addToHistory(value); + } ctrl.inputWidget.hide(); if (!value) { return; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts new file mode 100644 index 00000000000..3b501b8a03e --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HistoryNavigator2 } from '../../../../base/common/history.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const IInlineChatHistoryService = createDecorator('IInlineChatHistoryService'); + +export interface IInlineChatHistoryService { + readonly _serviceBrand: undefined; + + addToHistory(value: string): void; + previousValue(): string | undefined; + nextValue(): string | undefined; + isAtEnd(): boolean; + replaceLast(value: string): void; + resetCursor(): void; +} + +const _storageKey = 'inlineChat.history'; +const _capacity = 50; + +export class InlineChatHistoryService extends Disposable implements IInlineChatHistoryService { + declare readonly _serviceBrand: undefined; + + private readonly _history: HistoryNavigator2; + + constructor( + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + const raw = this._storageService.get(_storageKey, StorageScope.PROFILE); + let entries: string[] = ['']; + if (raw) { + try { + const parsed: string[] = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + entries = parsed; + // Ensure there's always an empty uncommitted entry at the end + if (entries[entries.length - 1] !== '') { + entries.push(''); + } + } + } catch { + // ignore invalid data + } + } + + this._history = new HistoryNavigator2(entries, _capacity); + + this._store.add(this._storageService.onWillSaveState(() => { + this._saveToStorage(); + })); + } + + private _saveToStorage(): void { + const values = [...this._history].filter(v => v.length > 0); + if (values.length === 0) { + this._storageService.remove(_storageKey, StorageScope.PROFILE); + } else { + this._storageService.store(_storageKey, JSON.stringify(values), StorageScope.PROFILE, StorageTarget.USER); + } + } + + addToHistory(value: string): void { + this._history.replaceLast(value); + this._history.add(''); + } + + previousValue(): string | undefined { + return this._history.previous(); + } + + nextValue(): string | undefined { + return this._history.next(); + } + + isAtEnd(): boolean { + return this._history.isAtEnd(); + } + + replaceLast(value: string): void { + this._history.replaceLast(value); + } + + resetCursor(): void { + this._history.resetCursor(); + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 434a4b2e7fe..3675acc488e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -41,6 +41,7 @@ import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOpt import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; import { IInlineChatSession2 } from './inlineChatSessionService.js'; import { assertType } from '../../../../base/common/types.js'; +import { IInlineChatHistoryService } from './inlineChatHistoryService.js'; /** * Overlay widget that displays a vertical action bar menu. @@ -62,7 +63,6 @@ export class InlineChatInputWidget extends Disposable { private _anchorLeft: number = 0; private _anchorAbove: boolean = false; - constructor( private readonly _editorObs: ObservableCodeEditor, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @@ -70,6 +70,7 @@ export class InlineChatInputWidget extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @IModelService modelService: IModelService, @IConfigurationService configurationService: IConfigurationService, + @IInlineChatHistoryService private readonly _historyService: IInlineChatHistoryService, ) { super(); @@ -161,9 +162,17 @@ export class InlineChatInputWidget extends Disposable { const totalWidth = contentWidth.read(r) + editorPad + toolbarWidth.read(r); const minWidth = 220; const maxWidth = 600; - const clampedWidth = this._input.getOption(EditorOption.wordWrap) === 'on' - ? maxWidth - : Math.max(minWidth, Math.min(totalWidth, maxWidth)); + const midWidth = Math.round(maxWidth / 1.618); + let clampedWidth: number; + if (this._input.getOption(EditorOption.wordWrap) === 'on') { + clampedWidth = maxWidth; + } else if (totalWidth <= minWidth) { + clampedWidth = minWidth; + } else if (totalWidth <= midWidth) { + clampedWidth = midWidth; + } else { + clampedWidth = maxWidth; + } const lineHeight = this._input.getOption(EditorOption.lineHeight); const clampedHeight = Math.min(contentHeight.read(r), (3 * lineHeight)); @@ -214,15 +223,28 @@ export class InlineChatInputWidget extends Disposable { this._store.add(this._input.onDidBlurEditorText(() => inputWidgetFocused.set(false))); this._store.add(toDisposable(() => inputWidgetFocused.reset())); - // Handle key events: ArrowDown to move to actions + // Handle key events: ArrowUp/ArrowDown for history navigation and action bar focus this._store.add(this._input.onKeyDown(e => { - if (e.keyCode === KeyCode.DownArrow && !actionBar.isEmpty()) { + if (e.keyCode === KeyCode.UpArrow) { + const position = this._input.getPosition(); + if (position && position.lineNumber === 1) { + this._showPreviousHistoryValue(); + e.preventDefault(); + e.stopPropagation(); + } + } else if (e.keyCode === KeyCode.DownArrow) { const model = this._input.getModel(); const position = this._input.getPosition(); if (position && position.lineNumber === model.getLineCount()) { - e.preventDefault(); - e.stopPropagation(); - actionBar.focus(0); + if (!this._historyService.isAtEnd()) { + this._showNextHistoryValue(); + e.preventDefault(); + e.stopPropagation(); + } else if (!actionBar.isEmpty()) { + e.preventDefault(); + e.stopPropagation(); + actionBar.focus(0); + } } } })); @@ -254,6 +276,30 @@ export class InlineChatInputWidget extends Disposable { return this._input.getModel().getValue().trim(); } + addToHistory(value: string): void { + this._historyService.addToHistory(value); + } + + private _showPreviousHistoryValue(): void { + if (this._historyService.isAtEnd()) { + this._historyService.replaceLast(this._input.getModel().getValue()); + } + const value = this._historyService.previousValue(); + if (value !== undefined) { + this._input.getModel().setValue(value); + } + } + + private _showNextHistoryValue(): void { + if (this._historyService.isAtEnd()) { + return; + } + const value = this._historyService.nextValue(); + if (value !== undefined) { + this._input.getModel().setValue(value); + } + } + /** * Show the widget at the specified line. * @param lineNumber The line number to anchor the widget to @@ -263,6 +309,9 @@ export class InlineChatInputWidget extends Disposable { show(lineNumber: number, left: number, anchorAbove: boolean, placeholder: string, value?: string): void { this._showStore.clear(); + // Reset history cursor to the end (current uncommitted text) + this._historyService.resetCursor(); + // Clear input state this._input.updateOptions({ wordWrap: 'off', placeholder }); this._input.getModel().setValue(value ?? ''); diff --git a/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts b/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts new file mode 100644 index 00000000000..6235b798361 --- /dev/null +++ b/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { join } from '../../../../base/common/path.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IKeybindingItem, KeybindingsRegistry } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeybindingResolver } from '../../../../platform/keybinding/common/keybindingResolver.js'; +import { ResolvedKeybindingItem } from '../../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { IKeyboardMapper } from '../../../../platform/keyboardLayout/common/keyboardMapper.js'; +import { IMacLinuxKeyboardMapping, IWindowsKeyboardMapping } from '../../../../platform/keyboardLayout/common/keyboardLayout.js'; +import { MacLinuxKeyboardMapper } from '../../../services/keybinding/common/macLinuxKeyboardMapper.js'; +import { WindowsKeyboardMapper } from '../../../services/keybinding/common/windowsKeyboardMapper.js'; +import { IKeymapInfo, KeymapInfo } from '../../../services/keybinding/common/keymapInfo.js'; +import { EN_US_WIN_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.win.js'; +import { EN_US_DARWIN_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.darwin.js'; +import { EN_US_LINUX_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.linux.js'; +import { KeybindingIO, OutputBuilder } from '../../../services/keybinding/common/keybindingIO.js'; +import { getAllUnboundCommands } from '../../../services/keybinding/browser/unboundCommands.js'; + +export class KeybindingsExportContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.keybindingsExport'; + + constructor( + @INativeEnvironmentService private readonly nativeEnvironmentService: INativeEnvironmentService, + @IFileService private readonly fileService: IFileService, + @INativeHostService private readonly nativeHostService: INativeHostService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + if (this.productService.quality === 'stable') { + return; + } + + const outputPath = this.nativeEnvironmentService.exportDefaultKeybindings; + if (outputPath !== undefined) { + const defaultPath = join(this.nativeEnvironmentService.appRoot, 'doc'); + void this.exportDefaultKeybindingsAndQuit(outputPath || defaultPath); + } + } + + private async exportDefaultKeybindingsAndQuit(outputPath: string): Promise { + try { + const platforms: { os: OperatingSystem; filename: string }[] = [ + { os: OperatingSystem.Windows, filename: 'doc.keybindings.win.json' }, + { os: OperatingSystem.Macintosh, filename: 'doc.keybindings.osx.json' }, + { os: OperatingSystem.Linux, filename: 'doc.keybindings.linux.json' }, + ]; + + for (const { os, filename } of platforms) { + const content = KeybindingsExportContribution._getDefaultKeybindingsContentForOS(os); + const filePath = join(outputPath, filename); + await this.fileService.writeFile(URI.file(filePath), VSBuffer.fromString(content)); + this.logService.info(`[${KeybindingsExportContribution.ID}] Wrote ${filePath}`); + } + + await this.nativeHostService.exit(0); + } catch (error) { + this.logService.error(`[${KeybindingsExportContribution.ID}] Failed to generate default keybindings`, error); + await this.nativeHostService.exit(1); + } + } + + private static _getDefaultKeybindingsContentForOS(os: OperatingSystem): string { + const items = KeybindingsRegistry.getDefaultKeybindingsForOS(os); + const mapper = KeybindingsExportContribution._createKeyboardMapperForOS(os); + const resolved = KeybindingsExportContribution._resolveKeybindingItemsWithMapper(items, mapper); + const resolver = new KeybindingResolver(resolved, [], () => { }); + const defaultKeybindings = resolver.getDefaultKeybindings(); + const boundCommands = resolver.getDefaultBoundCommands(); + return ( + KeybindingsExportContribution._formatDefaultKeybindings(defaultKeybindings) + + '\n\n' + + KeybindingsExportContribution._formatAllCommandsAsComment(boundCommands) + ); + } + + private static _createKeyboardMapperForOS(os: OperatingSystem): IKeyboardMapper { + const layoutMap: Record = { + [OperatingSystem.Windows]: EN_US_WIN_LAYOUT, + [OperatingSystem.Macintosh]: EN_US_DARWIN_LAYOUT, + [OperatingSystem.Linux]: EN_US_LINUX_LAYOUT, + }; + const layout = layoutMap[os]; + const keymapInfo = new KeymapInfo(layout.layout, layout.secondaryLayouts, layout.mapping); + switch (os) { + case OperatingSystem.Windows: + return new WindowsKeyboardMapper(true, keymapInfo.mapping, false); + case OperatingSystem.Macintosh: + return new MacLinuxKeyboardMapper(true, keymapInfo.mapping, false, OperatingSystem.Macintosh); + case OperatingSystem.Linux: + return new MacLinuxKeyboardMapper(true, keymapInfo.mapping, false, OperatingSystem.Linux); + } + } + + private static _resolveKeybindingItemsWithMapper(items: IKeybindingItem[], mapper: IKeyboardMapper): ResolvedKeybindingItem[] { + const result: ResolvedKeybindingItem[] = []; + for (const item of items) { + const when = item.when || undefined; + const keybinding = item.keybinding; + if (!keybinding) { + result.push(new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, true, item.extensionId, item.isBuiltinExtension)); + } else { + const resolvedKeybindings = mapper.resolveKeybinding(keybinding); + for (let i = resolvedKeybindings.length - 1; i >= 0; i--) { + result.push(new ResolvedKeybindingItem(resolvedKeybindings[i], item.command, item.commandArgs, when, true, item.extensionId, item.isBuiltinExtension)); + } + } + } + return result; + } + + private static _formatDefaultKeybindings(defaultKeybindings: readonly ResolvedKeybindingItem[]): string { + const out = new OutputBuilder(); + out.writeLine('['); + const lastIndex = defaultKeybindings.length - 1; + defaultKeybindings.forEach((k, index) => { + KeybindingIO.writeKeybindingItem(out, k); + if (index !== lastIndex) { + out.writeLine(','); + } else { + out.writeLine(); + } + }); + out.writeLine(']'); + return out.toString(); + } + + private static _formatAllCommandsAsComment(boundCommands: Map): string { + const unboundCommands = getAllUnboundCommands(boundCommands); + const pretty = unboundCommands.sort().join('\n// - '); + return '// Here are other available commands: ' + '\n// - ' + pretty; + } +} + +registerWorkbenchContribution2( + KeybindingsExportContribution.ID, + KeybindingsExportContribution, + WorkbenchPhase.Eventually, +); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 7cb6a6efebd..d175c3a067a 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -43,11 +43,17 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { if (!isContributionEnabled(plugin.enablement.read(reader))) { continue; } + const servers = plugin.mcpServerDefinitions.read(reader); + if (servers.length === 0) { + continue; + } + seen.add(plugin.uri); let collectionState = this._collections.get(plugin.uri); if (!collectionState) { - collectionState = this.createCollectionState(plugin); + // note: all plugin servers are currently defined in the same file + collectionState = this.createCollectionState(plugin, servers[0].uri); this._collections.set(plugin.uri, collectionState); } } @@ -60,7 +66,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { })); } - private createCollectionState(plugin: IAgentPlugin) { + private createCollectionState(plugin: IAgentPlugin, manifestURI: URI) { const collectionId = `plugin.${plugin.uri}`; return this._mcpRegistry.registerCollection({ id: collectionId, @@ -72,7 +78,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { serverDefinitions: plugin.mcpServerDefinitions.map(defs => defs.map(d => this._toServerDefinition(collectionId, d)).filter(isDefined)), presentation: { - origin: plugin.uri, + origin: manifestURI, order: McpCollectionSortOrder.Plugin, }, }); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index b051bac13ef..c74df032d41 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -407,9 +407,10 @@ suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://githu } test('should resolve when task completes', async () => { + const getTaskResultStub = sinon.stub().resolves({ content: [{ type: 'text', text: 'result' }] }); const mockHandler = upcastPartial({ getTask: sinon.stub().resolves(createTask({ status: 'completed' })), - getTaskResult: sinon.stub().resolves({ content: [{ type: 'text', text: 'result' }] }) + getTaskResult: getTaskResultStub }); const task = store.add(new McpTask(createTask())); @@ -423,7 +424,7 @@ suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://githu const result = await task.result; assert.deepStrictEqual(result, { content: [{ type: 'text', text: 'result' }] }); - assert.ok((mockHandler.getTaskResult as sinon.SinonStub).calledWith({ taskId: 'task1' })); + assert.ok(getTaskResultStub.calledWith({ taskId: 'task1' })); }); test('should poll for task updates', async () => { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index 9e2704a79b6..eaf1dfe189a 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -751,8 +751,8 @@ export function inspectSetting(key: string, target: SettingsTarget, languageFilt return { isConfigured, inspected, targetSelector, inspectedLanguageOverrides, languageSelector: languageFilter }; } -function sanitizeId(id: string): string { - return id.replace(/[\.\/]/, '_'); +export function sanitizeId(id: string): string { + return id.replace(/[\.\/]/g, '_'); } export function settingKeyToDisplayFormat(key: string, groupId: string = '', isLanguageTagSetting: boolean = false): { category: string; label: string } { diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts index 98421c6ea63..0fd6956d676 100644 --- a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { settingKeyToDisplayFormat, parseQuery, IParsedQuery } from '../../browser/settingsTreeModels.js'; +import { settingKeyToDisplayFormat, parseQuery, IParsedQuery, sanitizeId } from '../../browser/settingsTreeModels.js'; suite('SettingsTree', () => { test('settingKeyToDisplayFormat', () => { @@ -329,5 +329,22 @@ suite('SettingsTree', () => { }); }); + test('sanitizeId replaces all dots and slashes', () => { + assert.deepStrictEqual( + [ + sanitizeId('root.editor.font.size'), + sanitizeId('group/subgroup/setting.key'), + sanitizeId('no-special-chars'), + sanitizeId('single.dot'), + ], + [ + 'root_editor_font_size', + 'group_subgroup_setting_key', + 'no-special-chars', + 'single_dot', + ] + ); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index 530b0c4ae0f..2d7e4bb96ef 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -1003,7 +1003,6 @@ export class RemoteAgentConnectionStatusListener extends Disposable implements I if (e.handled) { logService.info(`Error handled: Not showing a notification for the error.`); - console.log(`Error handled: Not showing a notification for the error.`); } else if (!this._reloadWindowShown) { this._reloadWindowShown = true; dialogService.confirm({ diff --git a/src/vs/workbench/contrib/scm/browser/activity.ts b/src/vs/workbench/contrib/scm/browser/activity.ts index d38a51b0654..1dc0f055c9c 100644 --- a/src/vs/workbench/contrib/scm/browser/activity.ts +++ b/src/vs/workbench/contrib/scm/browser/activity.ts @@ -15,7 +15,6 @@ import { IStatusbarEntry, IStatusbarService, StatusbarAlignment as MainThreadSta import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { EditorResourceAccessor } from '../../../common/editor.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { Iterable } from '../../../../base/common/iterator.js'; import { ITitleService } from '../../../services/title/browser/titleService.js'; import { IEditorGroupContextKeyProvider, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; @@ -30,7 +29,7 @@ const ActiveRepositoryContextKeys = { }; export class SCMActiveRepositoryController extends Disposable implements IWorkbenchContribution { - private readonly _repositories: IObservable>; + private readonly _visibleRepositories: IObservable; private readonly _activeRepositoryHistoryItemRefName: IObservable; private readonly _countBadgeConfig: IObservable<'all' | 'focused' | 'off'>; private readonly _countBadgeRepositories: IObservable }[]>; @@ -60,9 +59,9 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe this._countBadgeConfig = observableConfigValue<'all' | 'focused' | 'off'>('scm.countBadge', 'all', this.configurationService); - this._repositories = observableFromEvent(this, - Event.any(this.scmService.onDidAddRepository, this.scmService.onDidRemoveRepository), - () => Iterable.filter(this.scmService.repositories, r => r.provider.isHidden !== true)); + this._visibleRepositories = observableFromEvent(this, + Event.any(this.scmViewService.onDidChangeVisibleRepositories, this.scmService.onDidAddRepository, this.scmService.onDidRemoveRepository), + () => this.scmViewService.visibleRepositories); this._activeRepositoryHistoryItemRefName = derived(reader => { const activeRepository = this.scmViewService.activeRepository.read(reader); @@ -75,8 +74,8 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe this._countBadgeRepositories = derived(this, reader => { switch (this._countBadgeConfig.read(reader)) { case 'all': { - const repositories = this._repositories.read(reader); - return [...Iterable.map(repositories, r => ({ provider: r.provider, resourceCount: this._getRepositoryResourceCount(r) }))]; + const repositories = this._visibleRepositories.read(reader); + return repositories.map(r => ({ provider: r.provider, resourceCount: this._getRepositoryResourceCount(r) })); } case 'focused': { const activeRepository = this.scmViewService.activeRepository.read(reader); diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index ab1900fc1db..88c5c132598 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -1706,7 +1706,7 @@ export class SCMHistoryViewPane extends ViewPane { compact: true, showPointer: true }, - content: new MarkdownString(localize('scmGraphViewOutdated', "Please refresh the graph using the refresh action ($(refresh))."), { supportThemeIcons: true }), + content: new MarkdownString(localize('scmGraphViewOutdated', "Please refresh the graph using the refresh action ({0}).", '$(refresh)'), { supportThemeIcons: true }), position: { hoverPosition: HoverPosition.BELOW } diff --git a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts index b10f117bc6e..15cd622c7cd 100644 --- a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts +++ b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts @@ -360,7 +360,7 @@ export class TaskQuickPick extends Disposable { public static getSettingEntry(configurationService: IConfigurationService, type: string): (ITaskTwoLevelQuickPickEntry & { settingType: string }) | undefined { if (configurationService.getValue(`${type}.autoDetect`) === 'off') { return { - label: nls.localize('TaskQuickPick.changeSettingsOptions', "$(gear) {0} task detection is turned off. Enable {1} task detection...", + label: '$(gear) ' + nls.localize('TaskQuickPick.changeSettingsOptions', "{0} task detection is turned off. Enable {1} task detection...", type[0].toUpperCase() + type.slice(1), type), task: null, settingType: type, diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index f8fc98554eb..d303a0233ba 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -1343,9 +1343,11 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } async sendText(text: string, shouldExecute: boolean, bracketedPasteMode?: boolean): Promise { + const useBracketedPasteMode = (bracketedPasteMode || /[\r\n]/.test(text)) && this.xterm?.raw.modes.bracketedPasteMode; + // Apply bracketed paste sequences if the terminal has the mode enabled, this will prevent // the text from triggering keybindings and ensure new lines are handled properly - if (bracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { + if (useBracketedPasteMode) { text = `\x1b[200~${text}\x1b[201~`; } diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index afeac192274..72a329b8e39 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -179,11 +179,13 @@ if ($Global:__VSCodeState.IsA11yMode -eq "1") { # Check if the loaded PSReadLine already supports EnableScreenReaderMode $hasScreenReaderParam = (Get-Module -Name PSReadLine) -and (Get-Command Set-PSReadLineOption).Parameters.ContainsKey('EnableScreenReaderMode') - if (-not $hasScreenReaderParam) { + if (-not $hasScreenReaderParam -and $PSVersionTable.PSVersion -ge "7.0") { # The loaded PSReadLine lacks EnableScreenReaderMode (only available in 2.4.4-beta4+). # PowerShell 7.0+ skips autoloading PSReadLine when the OS reports a screen reader active. # When only VS Code's accessibility mode is enabled (no OS screen reader), # it's still loaded and must be removed to load our bundled copy. + # Skip this on Windows PowerShell 5.1 where removing the built-in PSReadLine 2.0.0 + # and replacing it can cause input handling issues (e.g. repeated Enter key presses). if (Get-Module -Name PSReadLine) { Remove-Module PSReadLine -Force } diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 8c3c2d9982a..dffd6ab5e73 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { deepStrictEqual, strictEqual } from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -123,7 +124,8 @@ suite('Workbench - TerminalInstance', () => { suite('TerminalInstance', () => { let terminalInstance: ITerminalInstance; - test('should create an instance of TerminalInstance with env from default profile', async () => { + + async function createTerminalInstance(): Promise { const instantiationService = workbenchInstantiationService({ configurationService: () => new TestConfigurationService({ files: {}, @@ -146,9 +148,25 @@ suite('Workbench - TerminalInstance', () => { instantiationService.stub(IEnvironmentVariableService, store.add(instantiationService.createInstance(EnvironmentVariableService))); instantiationService.stub(ITerminalInstanceService, store.add(new TestTerminalInstanceService())); instantiationService.stub(ITerminalService, { setNextCommandId: async () => { } } as Partial); - terminalInstance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, {})); - // //Wait for the teminalInstance._xtermReadyPromise to resolve - await new Promise(resolve => setTimeout(resolve, 100)); + const instance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, {})); + await instance.xtermReadyPromise; + return instance; + } + + async function waitForShellLaunchConfigEnv(instance: ITerminalInstance): Promise { + for (let i = 0; i < 50; i++) { + if (instance.shellLaunchConfig.env) { + return; + } + await timeout(0); + } + + throw new Error('Timed out waiting for shell launch config env'); + } + + test('should create an instance of TerminalInstance with env from default profile', async () => { + terminalInstance = await createTerminalInstance(); + await waitForShellLaunchConfigEnv(terminalInstance); deepStrictEqual(terminalInstance.shellLaunchConfig.env, { TEST: 'TEST' }); }); @@ -192,6 +210,46 @@ suite('Workbench - TerminalInstance', () => { // Verify that the task name is preserved strictEqual(taskTerminal.title, 'Test Task Name', 'Task terminal should preserve API-set title'); }); + + test('should use bracketed paste mode for multiline executed text when available', async () => { + const instance = await createTerminalInstance(); + const writes: string[] = []; + const processManager = (instance as unknown as { _processManager: { write(data: string): Promise } })._processManager; + const originalWrite = processManager.write; + const originalXterm = instance.xterm!; + const testRaw = Object.create(originalXterm.raw) as typeof originalXterm.raw; + Object.defineProperty(testRaw, 'modes', { + value: { + ...originalXterm.raw.modes, + bracketedPasteMode: true + }, + configurable: true + }); + const testXterm = Object.create(originalXterm) as typeof originalXterm; + Object.defineProperty(testXterm, 'raw', { + value: testRaw, + configurable: true + }); + Object.defineProperty(testXterm, 'scrollToBottom', { + value: () => { }, + configurable: true + }); + + processManager.write = async (data: string) => { + writes.push(data); + }; + instance.xterm = testXterm; + + try { + await instance.sendText('echo hello\nworld', true); + } finally { + processManager.write = originalWrite; + instance.xterm = originalXterm; + } + + strictEqual(writes.length, 1); + strictEqual(writes[0].replace(/\x1b/g, '\\x1b').replace(/\r/g, '\\r'), '\\x1b[200~echo hello\\rworld\\x1b[201~\\r'); + }); }); suite('parseExitResult', () => { test('should return no message for exit code = undefined', () => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index d7e2dea9c4e..5d1e90193ab 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { isNumber } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -32,6 +32,7 @@ import { CreateAndRunTaskTool, CreateAndRunTaskToolData } from './tools/task/cre import { GetTaskOutputTool, GetTaskOutputToolData } from './tools/task/getTaskOutputTool.js'; import { RunTaskTool, RunTaskToolData } from './tools/task/runTaskTool.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { ITrustedDomainService } from '../../../url/common/trustedDomainService.js'; import { ITerminalSandboxService, TerminalSandboxService } from '../common/terminalSandboxService.js'; // #region Services @@ -75,64 +76,102 @@ class OutputLocationMigrationContribution extends Disposable implements IWorkben } registerWorkbenchContribution2(OutputLocationMigrationContribution.ID, OutputLocationMigrationContribution, WorkbenchPhase.Eventually); -class ChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { +export class ChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'terminal.chatAgentTools'; + private readonly _runInTerminalToolRegistration = this._register(new MutableDisposable()); + private _runInTerminalToolRegistrationVersion = 0; + constructor( - @IInstantiationService instantiationService: IInstantiationService, - @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, ) { super(); // #region Terminal - const confirmTerminalCommandTool = instantiationService.createInstance(ConfirmTerminalCommandTool); - this._register(toolsService.registerTool(ConfirmTerminalCommandToolData, confirmTerminalCommandTool)); - const getTerminalOutputTool = instantiationService.createInstance(GetTerminalOutputTool); - this._register(toolsService.registerTool(GetTerminalOutputToolData, getTerminalOutputTool)); - this._register(toolsService.executeToolSet.addTool(GetTerminalOutputToolData)); + const confirmTerminalCommandTool = _instantiationService.createInstance(ConfirmTerminalCommandTool); + this._register(_toolsService.registerTool(ConfirmTerminalCommandToolData, confirmTerminalCommandTool)); + const getTerminalOutputTool = _instantiationService.createInstance(GetTerminalOutputTool); + this._register(_toolsService.registerTool(GetTerminalOutputToolData, getTerminalOutputTool)); + this._register(_toolsService.executeToolSet.addTool(GetTerminalOutputToolData)); - const awaitTerminalTool = instantiationService.createInstance(AwaitTerminalTool); - this._register(toolsService.registerTool(AwaitTerminalToolData, awaitTerminalTool)); - this._register(toolsService.executeToolSet.addTool(AwaitTerminalToolData)); + const awaitTerminalTool = _instantiationService.createInstance(AwaitTerminalTool); + this._register(_toolsService.registerTool(AwaitTerminalToolData, awaitTerminalTool)); + this._register(_toolsService.executeToolSet.addTool(AwaitTerminalToolData)); - const killTerminalTool = instantiationService.createInstance(KillTerminalTool); - this._register(toolsService.registerTool(KillTerminalToolData, killTerminalTool)); - this._register(toolsService.executeToolSet.addTool(KillTerminalToolData)); + const killTerminalTool = _instantiationService.createInstance(KillTerminalTool); + this._register(_toolsService.registerTool(KillTerminalToolData, killTerminalTool)); + this._register(_toolsService.executeToolSet.addTool(KillTerminalToolData)); - instantiationService.invokeFunction(createRunInTerminalToolData).then(runInTerminalToolData => { - const runInTerminalTool = instantiationService.createInstance(RunInTerminalTool); - this._register(toolsService.registerTool(runInTerminalToolData, runInTerminalTool)); - this._register(toolsService.executeToolSet.addTool(runInTerminalToolData)); - }); + this._registerRunInTerminalTool(); - const getTerminalSelectionTool = instantiationService.createInstance(GetTerminalSelectionTool); - this._register(toolsService.registerTool(GetTerminalSelectionToolData, getTerminalSelectionTool)); + const getTerminalSelectionTool = _instantiationService.createInstance(GetTerminalSelectionTool); + this._register(_toolsService.registerTool(GetTerminalSelectionToolData, getTerminalSelectionTool)); - const getTerminalLastCommandTool = instantiationService.createInstance(GetTerminalLastCommandTool); - this._register(toolsService.registerTool(GetTerminalLastCommandToolData, getTerminalLastCommandTool)); + const getTerminalLastCommandTool = _instantiationService.createInstance(GetTerminalLastCommandTool); + this._register(_toolsService.registerTool(GetTerminalLastCommandToolData, getTerminalLastCommandTool)); - this._register(toolsService.readToolSet.addTool(GetTerminalSelectionToolData)); - this._register(toolsService.readToolSet.addTool(GetTerminalLastCommandToolData)); + this._register(_toolsService.readToolSet.addTool(GetTerminalSelectionToolData)); + this._register(_toolsService.readToolSet.addTool(GetTerminalLastCommandToolData)); // #endregion // #region Tasks - const runTaskTool = instantiationService.createInstance(RunTaskTool); - this._register(toolsService.registerTool(RunTaskToolData, runTaskTool)); + const runTaskTool = _instantiationService.createInstance(RunTaskTool); + this._register(_toolsService.registerTool(RunTaskToolData, runTaskTool)); - const getTaskOutputTool = instantiationService.createInstance(GetTaskOutputTool); - this._register(toolsService.registerTool(GetTaskOutputToolData, getTaskOutputTool)); + const getTaskOutputTool = _instantiationService.createInstance(GetTaskOutputTool); + this._register(_toolsService.registerTool(GetTaskOutputToolData, getTaskOutputTool)); - const createAndRunTaskTool = instantiationService.createInstance(CreateAndRunTaskTool); - this._register(toolsService.registerTool(CreateAndRunTaskToolData, createAndRunTaskTool)); - this._register(toolsService.executeToolSet.addTool(RunTaskToolData)); - this._register(toolsService.executeToolSet.addTool(CreateAndRunTaskToolData)); - this._register(toolsService.readToolSet.addTool(GetTaskOutputToolData)); + const createAndRunTaskTool = _instantiationService.createInstance(CreateAndRunTaskTool); + this._register(_toolsService.registerTool(CreateAndRunTaskToolData, createAndRunTaskTool)); + this._register(_toolsService.executeToolSet.addTool(RunTaskToolData)); + this._register(_toolsService.executeToolSet.addTool(CreateAndRunTaskToolData)); + this._register(_toolsService.readToolSet.addTool(GetTaskOutputToolData)); // #endregion + + // Re-register run_in_terminal tool when sandbox-related settings change, + // so the tool description and input schema stay in sync with the current + // sandbox state. + this._register(this._configurationService.onDidChangeConfiguration(e => { + if ( + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) + ) { + this._registerRunInTerminalTool(); + } + })); + this._register(this._trustedDomainService.onDidChangeTrustedDomains(() => { + this._registerRunInTerminalTool(); + })); + } + + private _runInTerminalTool: RunInTerminalTool | undefined; + + private _registerRunInTerminalTool(): void { + const version = ++this._runInTerminalToolRegistrationVersion; + this._instantiationService.invokeFunction(createRunInTerminalToolData).then(runInTerminalToolData => { + if (this._store.isDisposed || version !== this._runInTerminalToolRegistrationVersion) { + return; + } + if (!this._runInTerminalTool) { + this._runInTerminalTool = this._register(this._instantiationService.createInstance(RunInTerminalTool)); + } + // Dispose old registration first so registerToolData doesn't throw + // "already registered" for the same tool ID. + this._runInTerminalToolRegistration.value = undefined; + const store = new DisposableStore(); + store.add(this._toolsService.registerToolData(runInTerminalToolData)); + store.add(this._toolsService.registerToolImplementation(runInTerminalToolData.id, this._runInTerminalTool)); + store.add(this._toolsService.executeToolSet.addTool(runInTerminalToolData)); + this._runInTerminalToolRegistration.value = store; + }); } } registerWorkbenchContribution2(ChatAgentToolsContribution.ID, ChatAgentToolsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts index 599f0f93b8c..b4fab0650d3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts @@ -45,6 +45,7 @@ export interface ICommandLineAnalyzerOptions { treeSitterLanguage: TreeSitterCommandParserLanguage; terminalToolSessionId: string; chatSessionResource: URI | undefined; + requiresUnsandboxConfirmation?: boolean; } export interface ICommandLineAnalyzerResult { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts index 39ed6639c52..7d75a0d38cb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts @@ -22,7 +22,7 @@ export class CommandLineSandboxAnalyzer extends Disposable implements ICommandLi } return { isAutoApproveAllowed: true, - forceAutoApproval: true, + forceAutoApproval: _options.requiresUnsandboxConfirmation ? false : true, }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts index 65a0a565068..1093ebfcabd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts @@ -58,6 +58,10 @@ export const ConfirmTerminalCommandToolData: IToolData = { }; export class ConfirmTerminalCommandTool extends RunInTerminalTool { + override get _enableCommandLineSandboxRewriting() { + return false; + } + override async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const preparedInvocation = await super.prepareToolInvocation(context, token); if (preparedInvocation) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index addf7bf2123..0fea4461bab 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -72,16 +72,16 @@ import { clamp } from '../../../../../../base/common/numbers.js'; import { IOutputAnalyzer } from './outputAnalyzer.js'; import { SandboxOutputAnalyzer } from './sandboxOutputAnalyzer.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; -import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; +import { ITerminalSandboxService, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js'; // #region Tool data const TOOL_REFERENCE_NAME = 'runInTerminal'; const LEGACY_TOOL_REFERENCE_FULL_NAMES = ['runCommands/runInTerminal']; -function createPowerShellModelDescription(shell: string): string { +function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const isWinPwsh = isWindowsPowerShell(shell); - return [ + const parts = [ `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, '', 'Command Execution:', @@ -106,6 +106,13 @@ function createPowerShellModelDescription(shell: string): string { '- For long-running tasks (e.g., servers), set isBackground=true', '- Returns a terminal ID for checking status and runtime later', '- Use Start-Job for background PowerShell jobs', + ]; + + if (isSandboxEnabled) { + parts.push(...createSandboxLines(networkDomains)); + } + + parts.push( '', 'Output Management:', '- Output is automatically truncated if longer than 60KB to prevent context overflow', @@ -121,10 +128,38 @@ function createPowerShellModelDescription(shell: string): string { '- Use Test-Path to check file/directory existence', '- Be specific with Select-Object properties to avoid excessive output', '- Avoid printing credentials unless absolutely required', - ].join('\n'); + ); + + return parts.join('\n'); } -function createGenericDescription(isSandboxEnabled: boolean): string { +function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { + const lines = [ + '', + 'Sandboxing:', + '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default', + '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', + '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', + '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', + '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc', + '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access', + ]; + if (networkDomains) { + const deniedSet = new Set(networkDomains.deniedDomains); + const effectiveAllowed = networkDomains.allowedDomains.filter(d => !deniedSet.has(d)); + if (effectiveAllowed.length === 0) { + lines.push('- All network access is blocked in the sandbox'); + } else { + lines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${effectiveAllowed.join(', ')}`); + } + if (networkDomains.deniedDomains.length > 0) { + lines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`); + } + } + return lines; +} + +function createGenericDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const parts = [` Command Execution: - Use && to chain simple commands on one line @@ -148,12 +183,7 @@ Background Processes: - Returns a terminal ID for checking status and runtime later`]; if (isSandboxEnabled) { - parts.push(` -Sandboxing: -- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default -- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true and prompt the user to bypass the sandbox -- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. 'Operation not permitted' errors, network failures, or file access errors, etc -- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason; the user will be prompted before it runs unsandboxed`); + parts.push(createSandboxLines(networkDomains).join('\n')); } parts.push(` @@ -173,20 +203,20 @@ Best Practices: return parts.join(''); } -function createBashModelDescription(isSandboxEnabled: boolean): string { +function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled), + createGenericDescription(isSandboxEnabled, networkDomains), '- Use [[ ]] for conditional tests instead of [ ]', '- Prefer $() over backticks for command substitution', '- Use set -e at start of complex commands to exit on errors' ].join('\n'); } -function createZshModelDescription(isSandboxEnabled: boolean): string { +function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled), + createGenericDescription(isSandboxEnabled, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use [[ ]] for conditional tests instead of [ ]', @@ -196,10 +226,10 @@ function createZshModelDescription(isSandboxEnabled: boolean): string { ].join('\n'); } -function createFishModelDescription(isSandboxEnabled: boolean): string { +function createFishModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent fish terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled), + createGenericDescription(isSandboxEnabled, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use test expressions for conditionals (no [[ ]] syntax)', @@ -223,15 +253,17 @@ export async function createRunInTerminalToolData( terminalSandboxService.isEnabled(), ]); + const networkDomains = isSandboxEnabled ? terminalSandboxService.getResolvedNetworkDomains() : undefined; + let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { - modelDescription = createPowerShellModelDescription(shell); + modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, networkDomains); } else if (shell && os && isZsh(shell, os)) { - modelDescription = createZshModelDescription(isSandboxEnabled); + modelDescription = createZshModelDescription(isSandboxEnabled, networkDomains); } else if (shell && os && isFish(shell, os)) { - modelDescription = createFishModelDescription(isSandboxEnabled); + modelDescription = createFishModelDescription(isSandboxEnabled, networkDomains); } else { - modelDescription = createBashModelDescription(isSandboxEnabled); + modelDescription = createBashModelDescription(isSandboxEnabled, networkDomains); } return { @@ -399,7 +431,14 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { RunInTerminalTool._activeExecutions.delete(id); return true; } - + /** + * Controls whether this tool wires up sandbox-specific command rewriting. + * This is separate from ITerminalSandboxService.isEnabled(), which reports + * whether terminal sandboxing is currently enabled for the running window. + */ + protected get _enableCommandLineSandboxRewriting() { + return true; + } constructor( @IChatService protected readonly _chatService: IChatService, @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -432,8 +471,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._register(this._instantiationService.createInstance(CommandLineCdPrefixRewriter)), this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)), this._register(this._instantiationService.createInstance(CommandLinePreventHistoryRewriter)), - this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter)), ]; + if (this._enableCommandLineSandboxRewriting) { + this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter))); + } this._commandLineAnalyzers = [ this._register(this._instantiationService.createInstance(CommandLineFileWriteAnalyzer, this._treeSitterCommandParser, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineFileWriteAnalyzer: ${message}`, args))), this._register(this._instantiationService.createInstance(CommandLineAutoApproveAnalyzer, this._treeSitterCommandParser, this._telemetry, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineAutoApproveAnalyzer: ${message}`, args))), @@ -601,6 +642,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { treeSitterLanguage: isPowerShell(shell, os) ? TreeSitterCommandParserLanguage.PowerShell : TreeSitterCommandParserLanguage.Bash, terminalToolSessionId, chatSessionResource, + requiresUnsandboxConfirmation, }; const commandLineAnalyzerResults = await Promise.all(this._commandLineAnalyzers.map(e => e.analyze(commandLineAnalyzerOptions))); @@ -616,7 +658,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const analyzersIsAutoApproveAllowed = commandLineAnalyzerResults.every(e => e.isAutoApproveAllowed); - const customActions = !requiresUnsandboxConfirmation && isEligibleForAutoApproval() && analyzersIsAutoApproveAllowed ? commandLineAnalyzerResults.map(e => e.customActions ?? []).flat() : undefined; + const customActions = isEligibleForAutoApproval() && analyzersIsAutoApproveAllowed ? commandLineAnalyzerResults.map(e => e.customActions ?? []).flat() : undefined; let shellType = basename(shell, '.exe'); if (shellType === 'powershell') { @@ -711,10 +753,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } if (requiresUnsandboxConfirmation) { - disclaimer = new MarkdownString([ - disclaimer?.value, - localize('runInTerminal.unsandboxed.disclaimer', "$(warning) This command will run outside the terminal sandbox and may access files, network resources, or system state that sandboxed commands cannot reach.") - ].filter(Boolean).join(' '), { supportThemeIcons: true, isTrusted: disclaimer?.isTrusted }); confirmationTitle = args.isBackground ? localize('runInTerminal.unsandboxed.background', "Run `{0}` command outside the sandbox in background?", shellType) : localize('runInTerminal.unsandboxed', "Run `{0}` command outside the sandbox?", shellType); @@ -747,7 +785,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } // If forceConfirmationReason is set, always show confirmation regardless of auto-approval - const shouldShowConfirmation = requiresUnsandboxConfirmation || (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; + const shouldShowConfirmation = (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; const confirmationMessage = requiresUnsandboxConfirmation ? new MarkdownString(localize( 'runInTerminal.unsandboxed.confirmationMessage', @@ -761,7 +799,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { title: confirmationTitle, message: confirmationMessage, disclaimer, - allowAutoConfirm: requiresUnsandboxConfirmation ? false : undefined, + allowAutoConfirm: undefined, terminalCustomActions: customActions, } : undefined; @@ -771,12 +809,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { : rawDisplayCommand; const escapedDisplayCommand = escapeMarkdownSyntaxTokens(displayCommand); const invocationMessage = toolSpecificData.commandLine.isSandboxWrapped - ? new MarkdownString(args.isBackground - ? localize('runInTerminal.invocation.sandbox.background', "$(lock) Running `{0}` in sandbox in background", escapedDisplayCommand) - : localize('runInTerminal.invocation.sandbox', "$(lock) Running `{0}` in sandbox", escapedDisplayCommand), { supportThemeIcons: true }) - : new MarkdownString(args.isBackground - ? localize('runInTerminal.invocation.background', "Running `{0}` in background", escapedDisplayCommand) - : localize('runInTerminal.invocation', "Running `{0}`", escapedDisplayCommand)); + ? args.isBackground + ? new MarkdownString('$(lock) ' + localize('runInTerminal.invocation.sandbox.background', "Running `{0}` in sandbox in background", escapedDisplayCommand), { supportThemeIcons: true }) + : new MarkdownString('$(lock) ' + localize('runInTerminal.invocation.sandbox', "Running `{0}` in sandbox", escapedDisplayCommand), { supportThemeIcons: true }) + : args.isBackground + ? new MarkdownString(localize('runInTerminal.invocation.background', "Running `{0}` in background", escapedDisplayCommand)) + : new MarkdownString(localize('runInTerminal.invocation', "Running `{0}`", escapedDisplayCommand)); return { invocationMessage, @@ -1000,12 +1038,23 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ? `Note: The tool simplified the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` : `Command is running in terminal with ID=${termId}` ); + const backgroundOutput = pollingResult?.modelOutputEvalResponse ?? pollingResult?.output; + const outputAnalyzerMessage = backgroundOutput + ? await this._getOutputAnalyzerMessage(undefined, backgroundOutput, command, didSandboxWrapCommand) + : undefined; if (pollingResult && pollingResult.modelOutputEvalResponse) { - resultText += `\n\ The command became idle with output:\n${pollingResult.modelOutputEvalResponse}`; + resultText += `\n\ The command became idle with output:\n`; + if (outputAnalyzerMessage) { + resultText += `${outputAnalyzerMessage}\n`; + } + resultText += pollingResult.modelOutputEvalResponse; } else if (pollingResult) { - resultText += `\n\ The command is still running, with output:\n${pollingResult.output}`; + resultText += `\n\ The command is still running, with output:\n`; + if (outputAnalyzerMessage) { + resultText += `${outputAnalyzerMessage}\n`; + } + resultText += pollingResult.output; } - const endCwd = await toolTerminal.instance.getCwdResource(); return { toolMetadata: { @@ -1200,14 +1249,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { resultText.push(`Note: Command timed out after ${timeoutValue}ms. Output collected so far is shown below and the command may still be running in terminal ID ${termId}.\n\n`); } - let outputAnalyzerMessage: string | undefined; - for (const analyzer of this._outputAnalyzers) { - const message = await analyzer.analyze({ exitCode, exitResult: terminalResult, commandLine: command, isSandboxWrapped: didSandboxWrapCommand }); - if (message) { - outputAnalyzerMessage = message; - break; - } - } + const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { resultText.push(`${outputAnalyzerMessage}\n`); } @@ -1242,6 +1284,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + private async _getOutputAnalyzerMessage(exitCode: number | undefined, exitResult: string, commandLine: string, isSandboxWrapped: boolean): Promise { + for (const analyzer of this._outputAnalyzers) { + const message = await analyzer.analyze({ exitCode, exitResult, commandLine, isSandboxWrapped }); + if (message) { + return message; + } + } + + return undefined; + } + private static readonly _maxImageFileSize = 5 * 1024 * 1024; /** diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 9c1799d9476..658c64ad05a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -30,7 +30,7 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer : TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem; return `Command failed while running in sandboxed mode. If the command failed due to sandboxing: - If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetwork}.allowedDomains. -- You can also rerun requestUnsandboxedExecution=true and prompt the user to bypass the sandbox. +- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. Here is the output of the command:\n`; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 77efbd88b4f..9b0409c7123 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -11,6 +11,7 @@ import { dirname, posix, win32 } from '../../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; +import { localize } from '../../../../../nls.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -22,9 +23,16 @@ import { TerminalChatAgentToolsSettingId } from './terminalChatAgentToolsConfigu import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; import { ITrustedDomainService } from '../../../url/common/trustedDomainService.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ILifecycleService, WillShutdownJoinerOrder } from '../../../../services/lifecycle/common/lifecycle.js'; export const ITerminalSandboxService = createDecorator('terminalSandboxService'); +export interface ITerminalSandboxResolvedNetworkDomains { + allowedDomains: string[]; + deniedDomains: string[]; +} + export interface ITerminalSandboxService { readonly _serviceBrand: undefined; isEnabled(): Promise; @@ -33,6 +41,7 @@ export interface ITerminalSandboxService { getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; + getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains; } export class TerminalSandboxService extends Disposable implements ITerminalSandboxService { @@ -50,6 +59,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private _appRoot: string; private _os: OperatingSystem = OS; private _defaultWritePaths: string[] = ['~/.npm']; + private static readonly _sandboxTempDirName = 'tmp'; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -59,6 +69,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IProductService private readonly _productService: IProductService, + @ILifecycleService private readonly _lifecycleService: ILifecycleService, ) { super(); this._appRoot = dirname(FileAccess.asFileUri('').path); @@ -87,6 +99,17 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => { this.setNeedsForceUpdateConfigFile(); })); + + this._register(this._lifecycleService.onWillShutdown(e => { + if (!this._tempDir) { + return; + } + e.join(this._cleanupSandboxTempDir(), { + id: 'join.deleteFilesInSandboxTempDir', + label: localize('deleteFilesInSandboxTempDir', "Delete Files in Sandbox Temp Dir"), + order: WillShutdownJoinerOrder.Default + }); + })); } public async isEnabled(): Promise { @@ -119,9 +142,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // Use ELECTRON_RUN_AS_NODE=1 to make Electron executable behave as Node.js // TMPDIR must be set as environment variable before the command // Quote shell arguments so the wrapped command cannot break out of the outer shell. - const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; + const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; if (this._remoteEnvDetails) { - return `${wrappedCommand}`; + return `"${this._execPath}" ${wrappedCommand}`; } return `ELECTRON_RUN_AS_NODE=1 ${wrappedCommand}`; } @@ -212,13 +235,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb if (await this.isEnabled()) { this._needsForceUpdateConfigFile = true; const remoteEnv = this._remoteEnvDetails || await this._remoteEnvDetailsPromise; - if (remoteEnv) { - this._tempDir = remoteEnv.tmpDir; - } else { - const environmentService = this._environmentService as IEnvironmentService & { tmpDir?: URI }; - this._tempDir = environmentService.tmpDir; - } + this._tempDir = this._getSandboxTempDirPath(remoteEnv); if (this._tempDir) { + await this._fileService.createFolder(this._tempDir); this._defaultWritePaths.push(this._tempDir.path); } if (!this._tempDir) { @@ -227,6 +246,42 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } } + private async _cleanupSandboxTempDir(): Promise { + if (!this._tempDir) { + return; + } + try { + await this._fileService.del(this._tempDir, { recursive: true, useTrash: false }); + } catch (error) { + this._logService.warn('TerminalSandboxService: Failed to delete sandbox temp dir', error); + } + } + + private _getSandboxTempDirPath(remoteEnv: IRemoteAgentEnvironment | null): URI | undefined { + if (remoteEnv?.userHome) { + return URI.joinPath(remoteEnv.userHome, this._productService.serverDataFolderName ?? this._productService.dataFolderName, TerminalSandboxService._sandboxTempDirName); + } + + const nativeEnv = this._environmentService as IEnvironmentService & { userHome?: URI }; + if (nativeEnv.userHome) { + return URI.joinPath(nativeEnv.userHome, this._productService.dataFolderName, TerminalSandboxService._sandboxTempDirName); + } + + return undefined; + } + + public getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { + const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; + let allowedDomains = networkSetting.allowedDomains ?? []; + if (networkSetting.allowTrustedDomains) { + allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); + } + return { + allowedDomains, + deniedDomains: networkSetting.deniedDomains ?? [] + }; + } + private _addTrustedDomainsToAllowedDomains(allowedDomains: string[]): string[] { const allowedDomainsSet = new Set(allowedDomains); for (const domain of this._trustedDomainService.trustedDomains) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 68c0165879a..596c35e9b1d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -6,12 +6,14 @@ import { strictEqual, ok } from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestProductService } from '../../../../../test/common/workbenchTestServices.js'; import { TerminalSandboxService } from '../../common/terminalSandboxService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -23,6 +25,7 @@ import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/common/remoteAgentEnvironment.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; suite('TerminalSandboxService - allowTrustedDomains', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -31,8 +34,12 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { let configurationService: TestConfigurationService; let trustedDomainService: MockTrustedDomainService; let fileService: MockFileService; + let lifecycleService: TestLifecycleService; let workspaceContextService: MockWorkspaceContextService; + let productService: IProductService; let createdFiles: Map; + let createdFolders: string[]; + let deletedFolders: string[]; class MockTrustedDomainService implements ITrustedDomainService { _serviceBrand: undefined; @@ -50,6 +57,15 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { createdFiles.set(uri.path, contentString); return {}; } + + async createFolder(uri: URI): Promise { + createdFolders.push(uri.path); + return {}; + } + + async del(uri: URI): Promise { + deletedFolders.push(uri.path); + } } class MockRemoteAgentService { @@ -132,11 +148,19 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { setup(() => { createdFiles = new Map(); + createdFolders = []; + deletedFolders = []; instantiationService = workbenchInstantiationService({}, store); configurationService = new TestConfigurationService(); trustedDomainService = new MockTrustedDomainService(); fileService = new MockFileService(); + lifecycleService = store.add(new TestLifecycleService()); workspaceContextService = new MockWorkspaceContextService(); + productService = { + ...TestProductService, + dataFolderName: '.test-data', + serverDataFolderName: '.test-server-data' + }; workspaceContextService.setWorkspaceFolders([URI.file('/workspace-one')]); // Setup default configuration @@ -155,9 +179,11 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { execPath: '/usr/bin/node' }); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IProductService, productService); instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); instantiationService.stub(ITrustedDomainService, trustedDomainService); instantiationService.stub(IWorkspaceContextService, workspaceContextService); + instantiationService.stub(ILifecycleService, lifecycleService); }); test('should filter out sole wildcard (*) from trusted domains', async () => { @@ -341,6 +367,28 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { ok(refreshedConfig.filesystem.allowWrite.includes('/configured/path'), 'Refreshed config should preserve configured allowWrite paths'); }); + test('should create sandbox temp dir under the server data folder', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + const expectedTempDir = URI.joinPath(URI.file('/home/user'), productService.serverDataFolderName ?? productService.dataFolderName, 'tmp'); + + strictEqual(sandboxService.getTempDir()?.path, expectedTempDir.path, 'Sandbox temp dir should live under the server data folder'); + strictEqual(createdFolders[0], expectedTempDir.path, 'Sandbox temp dir should be created before writing the config'); + ok(configPath?.startsWith(expectedTempDir.path), 'Sandbox config file should be written inside the sandbox temp dir'); + }); + + test('should delete sandbox temp dir on shutdown', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + const expectedTempDir = URI.joinPath(URI.file('/home/user'), productService.serverDataFolderName ?? productService.dataFolderName, 'tmp'); + + lifecycleService.fireShutdown(); + await Promise.all(lifecycleService.shutdownJoiners); + + strictEqual(lifecycleService.shutdownJoiners.length, 1, 'Shutdown should register a temp-dir cleanup joiner'); + strictEqual(deletedFolders[0], expectedTempDir.path, 'Shutdown should delete the sandbox temp dir'); + }); + test('should add ripgrep bin directory to PATH when wrapping command', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index eafc29f9891..7887858cbcc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -6,7 +6,7 @@ import { ok, strictEqual } from 'assert'; import { Separator } from '../../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isLinux, isWindows, OperatingSystem } from '../../../../../../base/common/platform.js'; import { count } from '../../../../../../base/common/strings.js'; @@ -33,16 +33,22 @@ import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/wi import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; -import { ILanguageModelToolsService, IPreparedToolInvocation, IToolInvocationPreparationContext, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocationPreparationContext, ToolDataSource, ToolSet, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; -import { RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; +import { createRunInTerminalToolData, RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; import { ShellIntegrationQuality } from '../../browser/toolTerminalCreator.js'; import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; import { TerminalChatService } from '../../../chat/browser/terminalChatService.js'; import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; import { IAgentSession } from '../../../../chat/browser/agentSessions/agentSessionsModel.js'; +import { isDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; +import { ChatAgentToolsContribution } from '../../browser/terminal.chatAgentTools.contribution.js'; +import { TerminalToolId } from '../../browser/tools/toolIds.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; class TestRunInTerminalTool extends RunInTerminalTool { protected override _osBackend: Promise = Promise.resolve(OperatingSystem.Windows); @@ -116,6 +122,7 @@ suite('RunInTerminalTool', () => { getTempDir: () => undefined, setNeedsForceUpdateConfigFile: () => { }, getOS: async () => OperatingSystem.Linux, + getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }), }; instantiationService.stub(ITerminalSandboxService, terminalSandboxService); @@ -204,6 +211,76 @@ suite('RunInTerminalTool', () => { } suite('sandbox invocation messaging', () => { + test('should instruct models to use $TMPDIR instead of /tmp when sandboxed', async () => { + sandboxEnabled = true; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('must utilize the $TMPDIR environment variable'), 'Expected sandboxed tool description to require $TMPDIR usage'); + ok(toolData.modelDescription?.includes('The /tmp directory is not guaranteed to be accessible or writable and must be avoided'), 'Expected sandboxed tool description to discourage /tmp usage'); + }); + + test('should include requestUnsandboxedExecution in schema when sandbox is enabled', async () => { + sandboxEnabled = true; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + const properties = toolData.inputSchema?.properties as Record | undefined; + + ok(properties?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution in schema when sandbox is enabled'); + ok(properties?.['requestUnsandboxedExecutionReason'], 'Expected requestUnsandboxedExecutionReason in schema when sandbox is enabled'); + }); + + test('should not include requestUnsandboxedExecution in schema when sandbox is disabled', async () => { + sandboxEnabled = false; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + const properties = toolData.inputSchema?.properties as Record | undefined; + + ok(!properties?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution in schema when sandbox is disabled'); + ok(!properties?.['requestUnsandboxedExecutionReason'], 'Expected no requestUnsandboxedExecutionReason in schema when sandbox is disabled'); + }); + + test('should reflect sandbox setting changes in tool data', async () => { + sandboxEnabled = false; + + const toolDataBefore = await instantiationService.invokeFunction(createRunInTerminalToolData); + const propertiesBefore = toolDataBefore.inputSchema?.properties as Record | undefined; + ok(!propertiesBefore?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution before enabling sandbox'); + + sandboxEnabled = true; + + const toolDataAfter = await instantiationService.invokeFunction(createRunInTerminalToolData); + const propertiesAfter = toolDataAfter.inputSchema?.properties as Record | undefined; + ok(propertiesAfter?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution after enabling sandbox'); + ok(toolDataAfter.modelDescription?.includes('Sandboxing:'), 'Expected sandbox instructions in description after enabling sandbox'); + }); + + test('should include allowed and denied network domains in model description', async () => { + sandboxEnabled = true; + terminalSandboxService.getResolvedNetworkDomains = () => ({ + allowedDomains: ['github.com', 'npmjs.org'], + deniedDomains: ['evil.com'], + }); + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('github.com, npmjs.org'), 'Expected allowed domains in description'); + ok(toolData.modelDescription?.includes('evil.com'), 'Expected denied domains in description'); + }); + + test('should exclude denied domains from effective allowed list', async () => { + sandboxEnabled = true; + terminalSandboxService.getResolvedNetworkDomains = () => ({ + allowedDomains: ['github.com', 'evil.com', 'npmjs.org'], + deniedDomains: ['evil.com'], + }); + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('github.com, npmjs.org'), 'Expected effective allowed list without denied domain'); + ok(!toolData.modelDescription?.includes('accessible in the sandbox (all other network access is blocked): github.com, evil.com'), 'Expected denied domain removed from allowed list'); + }); + test('should use sandbox labels when command is sandbox wrapped', async () => { terminalSandboxService.isEnabled = async () => true; terminalSandboxService.getSandboxConfigPath = async () => '/tmp/vscode-sandbox-settings.json'; @@ -467,7 +544,7 @@ suite('RunInTerminalTool', () => { }); assertConfirmationRequired(result, 'Run `bash` command outside the sandbox?'); - strictEqual(result?.confirmationMessages?.allowAutoConfirm, false); + strictEqual(result?.confirmationMessages?.allowAutoConfirm, undefined); const terminalData = result?.toolSpecificData as IChatTerminalToolInvocationData; strictEqual(terminalData.requestUnsandboxedExecution, true); strictEqual(terminalData.requestUnsandboxedExecutionReason, 'Needs network access outside the sandbox'); @@ -480,13 +557,16 @@ suite('RunInTerminalTool', () => { } ok(confirmationMessage.value.includes('Reason for leaving the sandbox: Needs network access outside the sandbox')); - const disclaimer = result?.confirmationMessages?.disclaimer; - ok(disclaimer && typeof disclaimer !== 'string'); - if (!disclaimer || typeof disclaimer === 'string') { - throw new Error('Expected markdown disclaimer'); - } - ok(disclaimer.value.includes('outside the terminal sandbox')); - strictEqual(result?.confirmationMessages?.terminalCustomActions, undefined); + strictEqual(result?.confirmationMessages?.disclaimer, undefined); + const actions = result?.confirmationMessages?.terminalCustomActions; + ok(actions, 'Expected custom actions to be defined'); + strictEqual(actions.length, 11); + ok(!isSeparator(actions[0])); + strictEqual(actions[0].label, 'Allow `echo …` in this Session'); + ok(!isSeparator(actions[4])); + strictEqual(actions[4].label, 'Allow Exact Command Line in this Session'); + ok(!isSeparator(actions[10])); + strictEqual(actions[10].label, 'Configure Auto Approve...'); }); }); @@ -1677,3 +1757,196 @@ suite('RunInTerminalTool', () => { }); }); }); + +suite('ChatAgentToolsContribution - tool registration refresh', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let registeredToolData: Map; + let trustedDomainsEmitter: Emitter; + let sandboxEnabled: boolean; + + setup(() => { + configurationService = new TestConfigurationService(); + registeredToolData = new Map(); + trustedDomainsEmitter = store.add(new Emitter()); + sandboxEnabled = false; + + const logService = new NullLogService(); + const fileService = store.add(new FileService(logService)); + const fileSystemProvider = new TestIPCFileSystemProvider(); + store.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); + + const terminalServiceDisposeEmitter = store.add(new Emitter()); + const chatServiceDisposeEmitter = store.add(new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>()); + const chatSessionArchivedEmitter = store.add(new Emitter()); + + instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService, + fileService: () => fileService, + }, store); + + instantiationService.stub(IChatService, { + onDidDisposeSession: chatServiceDisposeEmitter.event, + getSession: () => undefined, + }); + instantiationService.stub(IAgentSessionsService, { + onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, + model: { + onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, + } as IAgentSessionsService['model'] + }); + instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService))); + instantiationService.stub(IHistoryService, { + getLastActiveWorkspaceRoot: () => undefined + }); + + const terminalSandboxService: ITerminalSandboxService = { + _serviceBrand: undefined, + isEnabled: async () => sandboxEnabled, + wrapCommand: (command: string) => `sandbox:${command}`, + getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined, + getTempDir: () => undefined, + setNeedsForceUpdateConfigFile: () => { }, + getOS: async () => OperatingSystem.Linux, + getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }), + }; + instantiationService.stub(ITerminalSandboxService, terminalSandboxService); + + const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService)); + treeSitterLibraryService.isTest = true; + instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService); + + instantiationService.stub(ITerminalService, { + onDidDisposeInstance: terminalServiceDisposeEmitter.event, + setNextCommandId: async () => { } + }); + instantiationService.stub(ITerminalProfileResolverService, { + getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile) + }); + + instantiationService.stub(ITrustedDomainService, { + _serviceBrand: undefined, + onDidChangeTrustedDomains: trustedDomainsEmitter.event, + isValid: () => true, + trustedDomains: [], + }); + + const contextKeyService = instantiationService.get(IContextKeyService); + const registeredToolImpls = new Map(); + const mockToolsService: Partial = { + _serviceBrand: undefined, + onDidChangeTools: Event.None, + registerToolData(toolData: IToolData) { + registeredToolData.set(toolData.id, toolData); + return toDisposable(() => registeredToolData.delete(toolData.id)); + }, + registerToolImplementation(id: string, tool: IToolImpl) { + registeredToolImpls.set(id, tool); + return toDisposable(() => registeredToolImpls.delete(id)); + }, + registerTool(toolData: IToolData, tool: IToolImpl) { + registeredToolData.set(toolData.id, toolData); + registeredToolImpls.set(toolData.id, tool); + return toDisposable(() => { + registeredToolData.delete(toolData.id); + registeredToolImpls.delete(toolData.id); + if (isDisposable(tool)) { + tool.dispose(); + } + }); + }, + getTools() { + return registeredToolData.values(); + }, + executeToolSet: new ToolSet('execute', 'execute', Codicon.play, ToolDataSource.Internal, undefined, undefined, contextKeyService), + readToolSet: new ToolSet('read', 'read', Codicon.book, ToolDataSource.Internal, undefined, undefined, contextKeyService), + }; + instantiationService.stub(ILanguageModelToolsService, mockToolsService as ILanguageModelToolsService); + }); + + async function flushAsync(): Promise { + // Multiple microtask cycles to let async _registerRunInTerminalTool complete + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + async function createContribution(): Promise { + const contribution = store.add(instantiationService.createInstance(ChatAgentToolsContribution)); + await flushAsync(); + return contribution; + } + + test('should register run_in_terminal tool on construction', async () => { + await createContribution(); + ok(registeredToolData.has(TerminalToolId.RunInTerminal), 'Expected run_in_terminal tool to be registered'); + }); + + test('should refresh run_in_terminal tool data when sandbox setting changes', async () => { + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + const propertiesBefore = toolDataBefore.inputSchema?.properties as Record | undefined; + ok(!propertiesBefore?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution before enabling sandbox'); + + // Enable sandbox and fire config change + sandboxEnabled = true; + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxEnabled]), + source: ConfigurationTarget.USER, + change: null!, + }); + + // Wait for async registration + await flushAsync(); + + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered'); + const propertiesAfter = toolDataAfter.inputSchema?.properties as Record | undefined; + ok(propertiesAfter?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution after enabling sandbox'); + }); + + test('should refresh run_in_terminal tool data when trusted domains change', async () => { + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + + // Fire trusted domains change + trustedDomainsEmitter.fire(); + + // Wait for async registration + await flushAsync(); + + // Tool should still be registered (re-registered with fresh data) + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered after trusted domains change'); + }); + + test('should refresh run_in_terminal tool data when sandbox network setting changes', async () => { + sandboxEnabled = true; + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + + // Fire network config change + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetwork]), + source: ConfigurationTarget.USER, + change: null!, + }); + + // Wait for async registration + await flushAsync(); + + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered after network setting change'); + }); +}); diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 6a9ea130312..9051baf1c59 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -71,7 +71,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc switch (state.type) { case StateType.CheckingForUpdates: this.updateEntry( - localize('updateStatus.checkingForUpdates', "$(loading~spin) Checking for updates..."), + '$(loading~spin) ' + localize('updateStatus.checkingForUpdates', "Checking for updates..."), localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), ShowTooltipCommand, ); @@ -79,7 +79,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.AvailableForDownload: this.updateEntry( - localize('updateStatus.updateAvailableStatus', "$(circle-filled) Update available, click to download."), + '$(circle-filled) ' + localize('updateStatus.updateAvailableStatus', "Update available, click to download."), localize('updateStatus.updateAvailableAria', "Update available, click to download."), 'update.downloadNow' ); @@ -95,7 +95,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Downloaded: this.updateEntry( - localize('updateStatus.updateReadyStatus', "$(circle-filled) Update downloaded, click to install."), + '$(circle-filled) ' + localize('updateStatus.updateReadyStatus', "Update downloaded, click to install."), localize('updateStatus.updateReadyAria', "Update downloaded, click to install."), 'update.install' ); @@ -111,7 +111,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Ready: this.updateEntry( - localize('updateStatus.restartToUpdateStatus', "$(circle-filled) Update is ready, click to restart."), + '$(circle-filled) ' + localize('updateStatus.restartToUpdateStatus', "Update is ready, click to restart."), localize('updateStatus.restartToUpdateAria', "Update is ready, click to restart."), 'update.restart' ); @@ -119,7 +119,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Overwriting: this.updateEntry( - localize('updateStatus.downloadingNewerUpdateStatus', "$(loading~spin) Downloading update..."), + '$(loading~spin) ' + localize('updateStatus.downloadingNewerUpdateStatus', "Downloading update..."), localize('updateStatus.downloadingNewerUpdateAria', "Downloading a newer update"), ShowTooltipCommand ); @@ -155,21 +155,21 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc private getDownloadingText({ downloadedBytes, totalBytes }: Downloading): string { if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { const percent = computeProgressPercent(downloadedBytes, totalBytes) ?? 0; - return localize('updateStatus.downloadUpdateProgressStatus', "$(loading~spin) Downloading update: {0} / {1} • {2}%", + return '$(loading~spin) ' + localize('updateStatus.downloadUpdateProgressStatus', "Downloading update: {0} / {1} • {2}%", formatBytes(downloadedBytes), formatBytes(totalBytes), percent); } else { - return localize('updateStatus.downloadUpdateStatus', "$(loading~spin) Downloading update..."); + return '$(loading~spin) ' + localize('updateStatus.downloadUpdateStatus', "Downloading update..."); } } private getUpdatingText({ currentProgress, maxProgress }: Updating): string { const percentage = computeProgressPercent(currentProgress, maxProgress); if (percentage !== undefined) { - return localize('updateStatus.installingUpdateProgressStatus', "$(loading~spin) Installing update: {0}%", percentage); + return '$(loading~spin) ' + localize('updateStatus.installingUpdateProgressStatus', "Installing update: {0}%", percentage); } else { - return localize('updateStatus.installingUpdateStatus', "$(loading~spin) Installing update..."); + return '$(loading~spin) ' + localize('updateStatus.installingUpdateStatus', "Installing update..."); } } } diff --git a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts index a5236c4b082..90bfdf715b5 100644 --- a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts +++ b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts @@ -130,6 +130,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private badPath: string | undefined; private remoteAgentEnvironment: IRemoteAgentEnvironment | null | undefined; private separator: string = '/'; + + /** + * When set, the dialog is scoped to a specific URI authority (e.g. + * for browsing an `agenthost://{authority}/...` filesystem that + * uses per-connection authorities rather than the global + * {@link remoteAuthority}). + */ + private scopedAuthority: string | undefined; private readonly onBusyChangeEmitter = this._register(new Emitter()); private updatingPromise: CancelablePromise | undefined; @@ -191,6 +199,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showOpenDialog(options: IOpenDialogOptions = {}): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); const newOptions = this.getOptions(options); @@ -207,6 +216,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showSaveDialog(options: ISaveDialogOptions): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); this.requiresTrailing = true; @@ -251,6 +261,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { if (!path.startsWith('\\\\')) { path = path.replace(/\\/g, '/'); } + // When scoped to a specific authority (e.g. agenthost://host/...), + // construct the URI directly with the authority to avoid + // toLocalResource stripping or replacing it. + if (this.scopedAuthority) { + return URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path, query: hintUri?.query, fragment: hintUri?.fragment }); + } const uri: URI = this.scheme === Schemas.file ? URI.file(path) : URI.from({ scheme: this.scheme, path, query: hintUri?.query, fragment: hintUri?.fragment }); // If the default scheme is file, then we don't care about the remote authority or the hint authority const authority = (uri.scheme === Schemas.file) ? undefined : (this.remoteAuthority ?? hintUri?.authority); @@ -272,6 +288,24 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { return Schemas.file; } + /** + * Returns the per-URI authority from {@link defaultUri} if the dialog + * should be scoped to a specific authority (e.g. `agenthost://host/...`). + * + * Returns `undefined` when the authority matches the global + * {@link remoteAuthority} (standard SSH remotes), since that path is + * already handled by the existing logic. + */ + private getScopedAuthority(defaultUri: URI | undefined): string | undefined { + if (defaultUri + && defaultUri.scheme === this.scheme + && defaultUri.authority + && defaultUri.authority !== this.remoteAuthority) { + return defaultUri.authority; + } + return undefined; + } + private async getRemoteAgentEnvironment(): Promise { if (this.remoteAgentEnvironment === undefined) { this.remoteAgentEnvironment = await this.remoteAgentService.getEnvironment(); @@ -280,6 +314,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } protected getUserHome(trueHome = false): Promise { + // When scoped to a custom authority, the platform userHome is not + // meaningful (it would return a local file:// path). Use the root + // of the scoped filesystem as the home directory instead. + if (this.scopedAuthority) { + return Promise.resolve(URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path: '/' })); + } return trueHome ? this.pathService.userHome({ preferLocal: this.scheme === Schemas.file }) : this.fileDialogService.preferredHome(this.scheme); @@ -295,9 +335,9 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private async pickResource(isSave: boolean = false): Promise { this.allowFolderSelection = !!this.options.canSelectFolders; this.allowFileSelection = !!this.options.canSelectFiles; - this.separator = this.labelService.getSeparator(this.scheme, this.remoteAuthority); + this.separator = this.scopedAuthority ? '/' : this.labelService.getSeparator(this.scheme, this.remoteAuthority); this.hidden = false; - this.isWindows = await this.checkIsWindowsOS(); + this.isWindows = this.scopedAuthority ? false : await this.checkIsWindowsOS(); let homedir: URI = this.options.defaultUri ? this.options.defaultUri : this.workspaceContextService.getWorkspace().folders[0].uri; let stat: IFileStatWithPartialMetadata | undefined; const ext: string = resources.extname(homedir); @@ -983,7 +1023,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private pathFromUri(uri: URI, endWithSeparator: boolean = false): string { - let result: string = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + // For authority-scoped schemes, use the raw path component instead + // of fsPath, which would prepend the authority as a UNC prefix. + let result: string; + if (this.scopedAuthority) { + result = uri.path.replace(/\n/g, ''); + } else { + result = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + } if (this.separator === '/') { result = result.replace(/\\/g, this.separator); } else { @@ -1024,7 +1071,11 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private async createBackItem(currFolder: URI): Promise { - const fileRepresentationCurr = this.currentFolder.with({ scheme: Schemas.file, authority: '' }); + // For authority-scoped URIs, compare within the original scheme so + // that the authority is preserved and the root is detected correctly. + const compareScheme = this.scopedAuthority ? this.scheme : Schemas.file; + const compareAuthority = this.scopedAuthority ?? ''; + const fileRepresentationCurr = this.currentFolder.with({ scheme: compareScheme, authority: compareAuthority }); const fileRepresentationParent = resources.dirname(fileRepresentationCurr); if (!resources.isEqual(fileRepresentationCurr, fileRepresentationParent)) { const parentFolder = resources.dirname(currFolder); diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index f9d57d5a53e..09dd590ee8a 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -643,7 +643,7 @@ export type removeArray = T extends Array ? X : T; export interface IExtensionPointDescriptor { extensionPoint: string; - deps?: IExtensionPoint[]; + deps?: IExtensionPoint[]; jsonSchema: IJSONSchema; defaultExtensionKind?: ExtensionKind[]; canHandleResolver?: boolean; @@ -674,7 +674,7 @@ export class ExtensionsRegistryImpl { return result; } - public getExtensionPoints(): ExtensionPoint[] { + public getExtensionPoints(): ExtensionPoint[] { return Array.from(this._extensionPoints.values()); } } diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts index e2e795a73b7..7cd617c5c87 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_DARWIN_LAYOUT: IKeymapInfo = { layout: { id: 'com.apple.keylayout.US', lang: 'en', localizedName: 'U.S.', isUSStandard: true }, secondaryLayouts: [ { id: 'com.apple.keylayout.ABC', lang: 'en', localizedName: 'ABC' }, @@ -137,4 +138,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ AltRight: [], MetaRight: [] } -}); \ No newline at end of file +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_DARWIN_LAYOUT); diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts index 639e7a4a86b..d1ea215b423 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_LINUX_LAYOUT: IKeymapInfo = { layout: { model: 'pc105', group: 0, layout: 'us', variant: '', options: '', rules: 'evdev', isUSStandard: true }, secondaryLayouts: [ { model: 'pc105', group: 0, layout: 'cn', variant: '', options: '', rules: 'evdev' }, @@ -187,4 +188,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ MailSend: [] } -}); +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_LINUX_LAYOUT); diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts index b1d6216120d..495d52aa52e 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_WIN_LAYOUT: IKeymapInfo = { layout: { name: '00000409', id: '', text: 'US', isUSStandard: true }, secondaryLayouts: [ { name: '00000804', id: '', text: 'Chinese (Simplified) - US Keyboard' }, @@ -171,4 +172,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ BrowserRefresh: [], BrowserFavorites: [] } -}); \ No newline at end of file +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_WIN_LAYOUT); diff --git a/src/vs/workbench/services/search/common/search.ts b/src/vs/workbench/services/search/common/search.ts index fd1743ceb68..b425f20be3e 100644 --- a/src/vs/workbench/services/search/common/search.ts +++ b/src/vs/workbench/services/search/common/search.ts @@ -647,11 +647,13 @@ export function isSerializedFileMatch(arg: ISerializedSearchProgressItem): arg i return !!(arg).path; } -export function isFilePatternMatch(candidate: IRawFileMatch, filePatternToUse: string, fuzzy = true): boolean { +const filePatternIgnoreCaseOptions = { ignoreCase: true }; + +export function isFilePatternMatch(candidate: IRawFileMatch, filePatternToUse: string, fuzzy = true, ignoreCase?: boolean): boolean { const pathToMatch = candidate.searchPath ? candidate.searchPath : candidate.relativePath; return fuzzy ? fuzzyContains(pathToMatch, filePatternToUse) : - glob.match(filePatternToUse, pathToMatch); + glob.match(filePatternToUse, pathToMatch, ignoreCase ? filePatternIgnoreCaseOptions : undefined); } export interface ISerializedFileMatch { diff --git a/src/vs/workbench/services/search/node/fileSearch.ts b/src/vs/workbench/services/search/node/fileSearch.ts index d850308c70c..cfb2819810b 100644 --- a/src/vs/workbench/services/search/node/fileSearch.ts +++ b/src/vs/workbench/services/search/node/fileSearch.ts @@ -591,7 +591,7 @@ export class FileWalker { if (this.normalizedFilePatternLowercase) { return isFilePatternMatch(candidate, this.normalizedFilePatternLowercase); } else if (this.filePattern) { - return isFilePatternMatch(candidate, this.filePattern, false); + return isFilePatternMatch(candidate, this.filePattern, false, this.config.ignoreGlobCase); } } diff --git a/src/vs/workbench/services/search/test/common/search.test.ts b/src/vs/workbench/services/search/test/common/search.test.ts index d2ccd94f59c..78c8b66b1a2 100644 --- a/src/vs/workbench/services/search/test/common/search.test.ts +++ b/src/vs/workbench/services/search/test/common/search.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ITextSearchPreviewOptions, OneLineRange, TextSearchMatch, SearchRange } from '../../common/search.js'; +import { ITextSearchPreviewOptions, OneLineRange, TextSearchMatch, SearchRange, isFilePatternMatch } from '../../common/search.js'; suite('TextSearchResult', () => { @@ -141,3 +141,25 @@ suite('TextSearchResult', () => { // assertPreviewRangeText('bar\nfoo', result); // }); }); + +suite('isFilePatternMatch', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('glob match is case-sensitive by default', () => { + const candidate = { relativePath: 'src/Foo.ts', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.ts', false), true); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.TS', false), false); + }); + + test('glob match is case-insensitive when ignoreCase is true', () => { + const candidate = { relativePath: 'src/Foo.ts', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.TS', false, true), true); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.Ts', false, true), true); + }); + + test('glob match with mixed case pattern', () => { + const candidate = { relativePath: 'src/MyComponent.TSX', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.tsx', false), false); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.tsx', false, true), true); + }); +}); diff --git a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts index aa2f6599439..e54c6549bc2 100644 --- a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts +++ b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts @@ -39,11 +39,11 @@ export function registerColorThemeExtensionPoint() { type: 'string' }, uiTheme: { - description: nls.localize('vscode.extension.contributes.themes.uiTheme', 'Base theme defining the colors around the editor: \'vs\' is the light color theme, \'vs-dark\' is the dark color theme. \'hc-black\' is the dark high contrast theme, \'hc-light\' is the light high contrast theme.'), + markdownDescription: nls.localize('vscode.extension.contributes.themes.uiTheme', 'Base theme defining the colors around the editor: `vs` is the light color theme, `vs-dark` is the dark color theme. `hc-black` is the dark high contrast theme, `hc-light` is the light high contrast theme.'), enum: [ThemeTypeSelector.VS, ThemeTypeSelector.VS_DARK, ThemeTypeSelector.HC_BLACK, ThemeTypeSelector.HC_LIGHT] }, path: { - description: nls.localize('vscode.extension.contributes.themes.path', 'Path of the tmTheme file. The path is relative to the extension folder and is typically \'./colorthemes/awesome-color-theme.json\'.'), + markdownDescription: nls.localize('vscode.extension.contributes.themes.path', 'Path of the tmTheme file. The path is relative to the extension folder and is typically `./colorthemes/awesome-color-theme.json`.'), type: 'string' } }, diff --git a/src/vs/workbench/services/views/common/viewContainerModel.ts b/src/vs/workbench/services/views/common/viewContainerModel.ts index fa24dca15c2..a6363627d1e 100644 --- a/src/vs/workbench/services/views/common/viewContainerModel.ts +++ b/src/vs/workbench/services/views/common/viewContainerModel.ts @@ -372,6 +372,10 @@ export class ViewContainerModel extends Disposable implements IViewContainerMode } } + refreshContainerInfo(): void { + this.updateContainerInfo(); + } + private isEqualIcon(icon: URI | ThemeIcon | undefined): boolean { if (URI.isUri(icon)) { return URI.isUri(this._icon) && isEqual(icon, this._icon); diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts new file mode 100644 index 00000000000..4fa628e23b8 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ResourceSet } from '../../../../base/common/map.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../contrib/chat/common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; +import { ParsedPromptFile, PromptHeader } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { isEqual } from '../../../../base/common/resources.js'; + +// Ensure color registrations are loaded +import '../../../../platform/theme/common/colors/inputColors.js'; +import '../../../../platform/theme/common/colors/listColors.js'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const defaultFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin], +}; + +interface IFixtureInstructionFile { + readonly promptPath: IPromptPath; + readonly name?: string; + readonly description?: string; + readonly applyTo?: string; /** If set, this instruction file has an applyTo pattern that controls automatic inclusion when the context matches (or `**` for always). */ +} + +function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): IPromptsService { + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeSkills = Event.None; + override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } + override async listPromptFiles(type: PromptsType) { + if (type === PromptsType.instructions) { + return instructionFiles.map(f => f.promptPath); + } + return []; + } + override async listAgentInstructions() { return agentInstructionFiles; } + override async getCustomAgents() { return []; } + override async parseNew(uri: URI): Promise { + const file = instructionFiles.find(f => isEqual(f.promptPath.uri, uri)); + const headerLines = []; + headerLines.push('---\n'); + if (file) { + if (file.name) { + headerLines.push(`name: ${file.name}\n`); + } + if (file.description) { + headerLines.push(`description: ${file.description}\n`); + } + if (file.applyTo) { + headerLines.push(`applyTo: "${file.applyTo}"\n`); + } + } + headerLines.push('---\n'); + const header = new PromptHeader( + new Range(2, 1, headerLines.length, 1), + uri, + headerLines + ); + return new ParsedPromptFile(uri, header); + } + }(); +} + +function createMockWorkspaceService(): IAICustomizationWorkspaceService { + const activeProjectRoot = observableValue('mockActiveProjectRoot', URI.file('/workspace')); + return new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = activeProjectRoot; + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter() { return defaultFilter; } + }(); +} + +function createMockHarnessService(): ICustomizationHarnessService { + const descriptor = createVSCodeHarnessDescriptor([PromptsStorage.extension]); + return new class extends mock() { + override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly availableHarnesses = observableValue('harnesses', [descriptor]); + override getStorageSourceFilter() { return defaultFilter; } + override getActiveDescriptor() { return descriptor; } + }(); +} + +function createMockWorkspaceContextService(): IWorkspaceContextService { + return new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { + return { id: 'test', folders: [] }; + } + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): Promise { + const width = 500; + const height = 400; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const contextMenuService = new class extends mock() { + override onDidShowContextMenu = Event.None; + override onDidHideContextMenu = Event.None; + override showContextMenu(): void { } + }; + + const contextViewService = new class extends mock() { + override anchorAlignment = 0; + override showContextView() { return { close: () => { } }; } + override hideContextView(): void { } + override getContextViewElement(): HTMLElement { return ctx.container; } + override layout(): void { } + }; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + reg.defineInstance(IContextMenuService, contextMenuService); + reg.defineInstance(IContextViewService, contextViewService); + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IPromptsService, createMockPromptsService(instructionFiles, agentInstructionFiles)); + reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); + reg.defineInstance(ICustomizationHarnessService, createMockHarnessService()); + reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = observableValue('plugins', []); + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + reg.defineInstance(IPathService, new class extends mock() { + override readonly defaultUriScheme = 'file'; + override userHome(): URI; + override userHome(): Promise; + override userHome(): URI | Promise { return URI.file('/home/dev'); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationListWidget) + ); + ctx.container.appendChild(widget.element); + await widget.setSection(AICustomizationManagementSection.Instructions); + widget.layout(height, width); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { + + InstructionsTabWithItems: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderInstructionsTab(ctx, [ + // Always-active instructions (no applyTo) + { promptPath: { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Coding Standards', description: 'Repository-wide coding standards' }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions }, name: 'My Style', description: 'Personal coding style preferences' }, + // Always-included instruction (applyTo: **) + { promptPath: { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'General Guidelines', description: 'General development guidelines', applyTo: '**' }, + // On-demand instructions (with applyTo pattern) + { promptPath: { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Testing Guidelines', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { promptPath: { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Security Review', description: 'Security review checklist', applyTo: 'src/auth/**' }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, extension: undefined!, source: undefined! }, name: 'TypeScript Rules', description: 'TypeScript conventions', applyTo: '**/*.ts' }, + ], [ + // Agent instruction files (AGENTS.md, copilot-instructions.md) + { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, + { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd }, + ]), + }), + + InstructionsTabEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderInstructionsTab(ctx, []), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts new file mode 100644 index 00000000000..f57c42abd49 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts @@ -0,0 +1,353 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { Dimension } from '../../../../base/browser/dom.js'; +import { IRenderedMarkdown } from '../../../../base/browser/markdownRenderer.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; +import { constObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IRequestService } from '../../../../platform/request/common/request.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; +import { IWebviewService } from '../../../contrib/webview/browser/webview.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createClaudeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots, getClaudeUserRoots } from '../../../contrib/chat/common/customizationHarnessService.js'; +import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; +import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { IPluginMarketplaceService } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { IPluginInstallService } from '../../../contrib/chat/common/plugins/pluginInstallService.js'; +import { AICustomizationManagementEditor } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpRegistry } from '../../../contrib/mcp/common/mcpRegistryTypes.js'; +import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; + +// Ensure theme colors & widget CSS are loaded +import '../../../../platform/theme/common/colors/inputColors.js'; +import '../../../../platform/theme/common/colors/listColors.js'; +import '../../../contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const userHome = URI.file('/home/dev'); +const BUILTIN_STORAGE = 'builtin'; + +interface IFixtureFile { + readonly uri: URI; + readonly storage: PromptsStorage; + readonly type: PromptsType; + readonly name?: string; + readonly description?: string; + readonly applyTo?: string; +} + +function createMockEditorGroup(): IEditorGroup { + return new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + }(); +} + +function createMockPromptsService(files: IFixtureFile[], agentInstructions: IResolvedAgentFile[]): IPromptsService { + const applyToMap = new ResourceMap(); + const descriptionMap = new ResourceMap(); + for (const f of files) { applyToMap.set(f.uri, f.applyTo); descriptionMap.set(f.uri, f.description); } + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeSkills = Event.None; + override readonly onDidChangeInstructions = Event.None; + override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } + override async listPromptFiles(type: PromptsType) { + return files.filter(f => f.type === type).map(f => ({ + uri: f.uri, storage: f.storage as PromptsStorage.local, type: f.type, name: f.name, description: f.description, + })); + } + override async listAgentInstructions() { return agentInstructions; } + override async getCustomAgents() { + return files.filter(f => f.type === PromptsType.agent).map(a => ({ + uri: a.uri, name: a.name ?? 'agent', description: a.description, storage: a.storage, + source: { storage: a.storage }, + })) as never[]; + } + override async parseNew(uri: URI, _token: CancellationToken): Promise { + const header = { + get applyTo() { return applyToMap.get(uri); }, + get description() { return descriptionMap.get(uri); }, + }; + return new ParsedPromptFile(uri, header as never); + } + override async getSourceFolders() { return [] as never[]; } + override async findAgentSkills() { return [] as never[]; } + override async getPromptSlashCommands() { return [] as never[]; } + }(); +} + +function createMockHarnessService(activeHarness: CustomizationHarness, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { + const active = observableValue('activeHarness', activeHarness); + return new class extends mock() { + override readonly activeHarness = active; + override readonly availableHarnesses = constObservable(descriptors); + override getStorageSourceFilter(type: PromptsType) { + const d = descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + return d.getStorageSourceFilter(type); + } + override getActiveDescriptor() { + return descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + } + override setActiveHarness(id: CustomizationHarness) { active.set(id, undefined); } + }(); +} + +function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScope, description?: string): IWorkbenchMcpServer { + return new class extends mock() { + override readonly id = id; + override readonly name = id; + override readonly label = label; + override readonly description = description ?? ''; + override readonly installState = McpServerInstallState.Installed; + override readonly local = new class extends mock() { + override readonly id = id; + override readonly scope = scope; + }(); + }(); +} + +// ============================================================================ +// Realistic test data — a project that has Copilot + Claude customizations +// ============================================================================ + +const allFiles: IFixtureFile[] = [ + // Copilot instructions + { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, + { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' }, + // Claude rules + { uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' }, + { uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' }, + { uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' }, + // Agents + { uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' }, + { uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' }, + { uri: URI.file('/workspace/.claude/agents/planner.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, + // Skills + { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, + { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, + // Prompts + { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, + { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, +]; + +const agentInstructions: IResolvedAgentFile[] = [ + { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, + { uri: URI.file('/workspace/CLAUDE.md'), realPath: undefined, type: AgentFileType.claudeMd }, + { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd }, +]; + +const mcpWorkspaceServers = [ + makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), + makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), +]; +const mcpUserServers = [ + makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'), +]; +const mcpRuntimeServers = [ + { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, +]; + +interface IRenderEditorOptions { + readonly harness: CustomizationHarness; + readonly isSessionsWindow?: boolean; + readonly managementSections?: readonly AICustomizationManagementSection[]; + readonly availableHarnesses?: readonly IHarnessDescriptor[]; +} + +// ============================================================================ +// Render helper — creates the full management editor +// ============================================================================ + +async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditorOptions): Promise { + const width = 900; + const height = 600; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const isSessionsWindow = options.isSessionsWindow ?? false; + const managementSections = options.managementSections ?? [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, + ]; + const availableHarnesses = options.availableHarnesses ?? [ + createVSCodeHarnessDescriptor([PromptsStorage.extension]), + createCliHarnessDescriptor(getCliUserRoots(userHome), []), + createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), []), + ]; + + const allMcpServers = [...mcpWorkspaceServers, ...mcpUserServers]; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + const harnessService = createMockHarnessService(options.harness, availableHarnesses); + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IPromptsService, createMockPromptsService(allFiles, agentInstructions)); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = isSessionsWindow; + override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter(type: PromptsType) { return harnessService.getStorageSourceFilter(type); } + override clearOverrideProjectRoot() { } + override setOverrideProjectRoot() { } + override readonly managementSections = managementSections; + override async generateCustomization() { } + }()); + reg.defineInstance(ICustomizationHarnessService, harnessService); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } + override getWorkbenchState(): WorkbenchState { return WorkbenchState.WORKSPACE; } + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + reg.defineInstance(IPathService, new class extends mock() { + override readonly defaultUriScheme = 'file'; + override userHome(): URI; + override userHome(): Promise; + override userHome(): URI | Promise { return userHome; } + }()); + reg.defineInstance(ITextModelService, new class extends mock() { }()); + reg.defineInstance(IWorkingCopyService, new class extends mock() { + override readonly onDidChangeDirty = Event.None; + }()); + reg.defineInstance(IFileDialogService, new class extends mock() { }()); + reg.defineInstance(IExtensionService, new class extends mock() { }()); + reg.defineInstance(IQuickInputService, new class extends mock() { }()); + reg.defineInstance(IRequestService, new class extends mock() { }()); + reg.defineInstance(IMarkdownRendererService, new class extends mock() { + override render() { + const rendered: IRenderedMarkdown = { + element: DOM.$('span'), + dispose() { }, + }; + return rendered; + } + }()); + reg.defineInstance(IWebviewService, new class extends mock() { }()); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local = allMcpServers; + override async queryLocal() { return allMcpServers; } + override canInstall() { return true as const; } + }()); + reg.defineInstance(IMcpService, new class extends mock() { + override readonly servers = constObservable(mcpRuntimeServers as never[]); + }()); + reg.defineInstance(IMcpRegistry, new class extends mock() { + override readonly collections = constObservable([]); + override readonly delegates = constObservable([]); + override readonly onDidChangeInputs = Event.None; + }()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([]); + override readonly enablementModel = undefined as never; + }()); + reg.defineInstance(IPluginMarketplaceService, new class extends mock() { + override readonly installedPlugins = constObservable([]); + override readonly onDidChangeMarketplaces = Event.None; + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + }, + }); + + const editor = ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationManagementEditor, createMockEditorGroup()) + ); + editor.create(ctx.container); + editor.layout(new Dimension(width, height)); + + // setInput may fail on unmocked service calls — catch to still show the editor shell + try { + await editor.setInput(AICustomizationManagementEditorInput.getOrCreate(), undefined, {}, CancellationToken.None); + } catch { + // Expected in fixture — some services are partially mocked + } +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { + + // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, + // Generate buttons, AGENTS.md shortcut, all storage groups + LocalHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), + }), + + // Full editor with Copilot CLI harness — no prompts section, CLI-specific + // root files and instruction filtering under .github/.copilot paths. + CliHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.CLI }), + }), + + // Full editor with Claude harness — Prompts+Plugins hidden, Agents visible, + // "Add CLAUDE.md" button, "New Rule" dropdown, instruction filtering, bridged MCP badge + ClaudeHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.Claude }), + }), + + // Sessions-window variant of the full editor with workspace override UX + // and sessions section ordering. + Sessions: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.CLI, + isSessionsWindow: true, + availableHarnesses: [ + createCliHarnessDescriptor(getCliUserRoots(userHome), [BUILTIN_STORAGE]), + ], + managementSections: [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, + ], + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts index cf16bc14f0c..2705eb47e21 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts @@ -10,6 +10,8 @@ import { mock } from '../../../../base/test/common/mock.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IMenuService, IMenu, MenuId, MenuItemAction, IMenuItem } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -32,7 +34,7 @@ import { IChatArtifactsService } from '../../../contrib/chat/common/tools/chatAr import { ChatEditingSessionState, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatRequestDisablement } from '../../../contrib/chat/common/model/chatModel.js'; import { IChatTodo, IChatTodoListService } from '../../../contrib/chat/common/tools/chatTodoListService.js'; -import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; +import { ChatAgentLocation, ChatConfiguration } from '../../../contrib/chat/common/constants.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { IChatModeService } from '../../../contrib/chat/common/chatModes.js'; import { IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; @@ -46,6 +48,7 @@ import { IWorkbenchLayoutService } from '../../../services/layout/browser/layout import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; import { INotebookDocumentService } from '../../../services/notebook/common/notebookDocumentService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; @@ -133,6 +136,7 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: reg.defineInstance(IActionWidgetService, new class extends mock() { override show() { } override hide() { } override get isVisible() { return false; } }()); reg.defineInstance(IProductService, new class extends mock() { }()); reg.defineInstance(IUpdateService, new class extends mock() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }()); + reg.defineInstance(IUriIdentityService, new class extends mock() { }()); reg.defineInstance(IChatArtifactsService, new class extends mock() { override readonly onDidUpdateArtifacts = Event.None; override getArtifacts() { return [...artifacts]; } @@ -149,6 +153,11 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: }, }); + if (artifacts.length > 0) { + const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configService.setUserConfiguration(ChatConfiguration.ArtifactsEnabled, true); + } + container.style.width = '500px'; container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; container.classList.add('monaco-workbench'); @@ -263,6 +272,9 @@ export default defineThemedFixtureGroup({ path: 'chat/input/' }, { WithTodos: defineComponentFixture({ render: context => renderChatInput(context, { todos: sampleTodos }) }), + WithTodosAndFileChanges: defineComponentFixture({ + render: context => renderChatInput(context, { todos: sampleTodos, editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) + }), WithArtifactsAndFileChanges: defineComponentFixture({ render: context => renderChatInput(context, { artifacts: sampleArtifacts, editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) }), diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 6c887e147dd..5a5c36698c1 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -192,6 +192,9 @@ import './contrib/mcp/electron-browser/mcp.contribution.js'; // Policy Export import './contrib/policyExport/electron-browser/policyExport.contribution.js'; +// Keybindings Export +import './contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.js'; + //#endregion diff --git a/src/vscode-dts/vscode.proposed.findFiles2.d.ts b/src/vscode-dts/vscode.proposed.findFiles2.d.ts index 8e7c11874b4..af324e90719 100644 --- a/src/vscode-dts/vscode.proposed.findFiles2.d.ts +++ b/src/vscode-dts/vscode.proposed.findFiles2.d.ts @@ -71,6 +71,12 @@ declare module 'vscode' { * For more info, see the setting description for `search.followSymlinks`. */ followSymlinks?: boolean; + + /** + * Whether glob patterns should be matched case-insensitively. + * Defaults to `false`. + */ + caseInsensitive?: boolean; } /** diff --git a/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts b/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts index c7c7c9507a5..4575ba17d0c 100644 --- a/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts +++ b/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts @@ -91,6 +91,12 @@ declare module 'vscode' { */ followSymlinks?: boolean; + /** + * Whether glob patterns should be matched case-insensitively. + * Defaults to `false`. + */ + caseInsensitive?: boolean; + /** * Interpret files using this encoding. * See the vscode setting `"files.encoding"`