diff --git a/.github/skills/chat-perf/SKILL.md b/.github/skills/chat-perf/SKILL.md index 872454cf9f717..7769d511cf41c 100644 --- a/.github/skills/chat-perf/SKILL.md +++ b/.github/skills/chat-perf/SKILL.md @@ -58,7 +58,9 @@ Launches VS Code via Playwright Electron, opens the chat panel, sends a message | `--production-build` | — | Build a local bundled package via `gulp vscode` for comparison against a release baseline. | | `--no-cache` | — | Ignore cached baseline data, always run fresh. | | `--force` | — | Skip build mode mismatch confirmation prompt. | -| `--ci` | — | CI mode: write Markdown summary to `ci-summary.md` (implies `--no-cache`). | +| `--ci` | — | CI mode: write Markdown summary to `ci-summary.md` (implies `--no-cache`, `--heap-snapshots`, `--cleanup-diagnostics`). | +| `--heap-snapshots` | — | Take heap snapshots after each run (slow; auto-enabled in `--ci` mode). | +| `--cleanup-diagnostics` | — | Delete heap snapshots, CPU profiles, and traces to save disk. During runs, only the latest run's files are kept; after comparison, files for non-regressed scenarios are deleted. Auto-enabled in `--ci` mode. | | `--setting ` | — | Set a VS Code setting override for all builds (repeatable). | | `--test-setting ` | — | Set a VS Code setting override for the test build only. | | `--baseline-setting ` | — | Set a VS Code setting override for the baseline build only. | diff --git a/.github/workflows/chat-perf.yml b/.github/workflows/chat-perf.yml index fc3e8aafd5e1e..52e7db5c73d8b 100644 --- a/.github/workflows/chat-perf.yml +++ b/.github/workflows/chat-perf.yml @@ -67,6 +67,7 @@ jobs: outputs: test_is_version: ${{ steps.resolve.outputs.is_version }} test_build_arg: ${{ steps.resolve.outputs.build_arg }} + perf_matrix: ${{ steps.count_scenarios.outputs.matrix }} steps: - name: Resolve test build type id: resolve @@ -143,6 +144,28 @@ jobs: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + - name: Compute perf matrix + id: count_scenarios + run: | + node -e " + require('./scripts/chat-simulation/common/perf-scenarios').registerPerfScenarios(); + const { getScenarioIds } = require('./scripts/chat-simulation/common/mock-llm-server'); + const userInput = process.env.SCENARIOS_INPUT || ''; + const parsed = userInput.split(',').map(s => s.trim()).filter(Boolean); + const allScens = parsed.length > 0 ? parsed : getScenarioIds(); + if (allScens.length === 0) { + console.error('No scenarios found. Provide a non-empty scenarios input or ensure getScenarioIds() returns at least one scenario.'); + process.exit(1); + } + const maxGroups = 4; + const needed = Math.min(allScens.length, maxGroups); + const groups = Array.from({ length: needed }, (_, i) => i + 1); + const fs = require('fs'); + const matrix = JSON.stringify({ group: groups }); + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'matrix=' + matrix + '\\n'); + console.log('Total scenarios: ' + allScens.length + ', groups: ' + needed); + " + - name: Upload build output uses: actions/upload-artifact@v7 with: @@ -160,8 +183,7 @@ jobs: timeout-minutes: 60 strategy: fail-fast: false - matrix: - group: [ 1, 2, 3, 4 ] + matrix: ${{ fromJSON(needs.setup.outputs.perf_matrix) }} steps: - name: Checkout uses: actions/checkout@v6 @@ -223,10 +245,9 @@ jobs: require('./scripts/chat-simulation/common/perf-scenarios').registerPerfScenarios(); const { getScenarioIds } = require('./scripts/chat-simulation/common/mock-llm-server'); const userInput = process.env.SCENARIOS_INPUT || ''; - const allScens = userInput - ? userInput.split(',').map(s => s.trim()).filter(Boolean) - : getScenarioIds(); - const groups = 4; + const parsed = userInput.split(',').map(s => s.trim()).filter(Boolean); + const allScens = parsed.length > 0 ? parsed : getScenarioIds(); + const groups = parseInt(process.env.TOTAL_GROUPS, 10); const group = parseInt(process.env.MATRIX_GROUP, 10); // Distribute scenarios round-robin across groups const groupScens = allScens.filter((_, i) => (i % groups) + 1 === group); @@ -242,6 +263,7 @@ jobs: " env: MATRIX_GROUP: ${{ matrix.group }} + TOTAL_GROUPS: ${{ strategy.job-total }} - name: Run chat perf comparison id: perf @@ -287,6 +309,16 @@ jobs: 2>&1 | tee perf-output.log echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + - name: Clean up temporary build artifacts + if: always() && steps.scenarios.outputs.skip != 'true' + run: | + # Clean up tmp dirs used by VS Code instances + rm -rf /tmp/vscode-chat-simulation 2>/dev/null || true + # Remove the production build to free space for artifact upload + rm -rf ../VSCode-* 2>/dev/null || true + echo "Disk usage after cleanup:" + df -h . | tail -1 + - name: Upload perf results if: always() && steps.scenarios.outputs.skip != 'true' uses: actions/upload-artifact@v7 @@ -298,6 +330,19 @@ jobs: .chat-simulation-data/ retention-days: 30 + - name: Upload perf summary data + if: always() && steps.scenarios.outputs.skip != 'true' + uses: actions/upload-artifact@v7 + with: + name: perf-summary-${{ matrix.group }} + include-hidden-files: true + path: | + perf-output.log + .chat-simulation-data/**/results.json + .chat-simulation-data/**/baseline-*.json + .chat-simulation-data/ci-summary.md + retention-days: 1 + - name: Check for regressions if: always() && steps.perf.outputs.exit_code != '' && steps.perf.outputs.exit_code != '0' @@ -380,6 +425,11 @@ jobs: 2>&1 | tee leak-output.log echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + - name: Clean up temporary files + if: always() + run: | + rm -rf /tmp/vscode-chat-simulation 2>/dev/null || true + - name: Upload leak results if: always() uses: actions/upload-artifact@v7 @@ -402,7 +452,7 @@ jobs: # ── Report: collect results, write summary, fail on regression ────── report: name: Report - needs: [ chat-perf, leak-check ] + needs: [ setup, chat-perf, leak-check ] if: always() runs-on: ubuntu-latest timeout-minutes: 30 @@ -417,10 +467,10 @@ jobs: with: node-version-file: .nvmrc - - name: Download all perf results + - name: Download perf summary data uses: actions/download-artifact@v7 with: - pattern: perf-results-* + pattern: perf-summary-* path: perf-results - name: Download leak results @@ -432,6 +482,8 @@ jobs: continue-on-error: true - name: Generate unified summary + env: + TEST_COMMIT: ${{ needs.setup.outputs.test_build_arg || github.sha }} run: | LEAK_ARG="" if [[ -f leak-results/.chat-simulation-data/ci-summary-leak.md ]]; then diff --git a/.vscode/settings.json b/.vscode/settings.json index 99f937bdf9dcb..ed355128faaf9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,6 +45,7 @@ ".git": true, ".build": true, ".profile-oss": true, + "**/*.tsbuildinfo": true, "**/.DS_Store": true, ".vscode-test": true, "cli/target": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7e3fbad02501a..503664cead07b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -47,12 +47,7 @@ "fileLocation": [ "absolute" ], - "pattern": { - "regexp": "Error: ([^(]+)\\((\\d+|\\d+,\\d+|\\d+,\\d+,\\d+,\\d+)\\): (.*)$", - "file": 1, - "location": 2, - "message": 3 - }, + "pattern": "$tsc", "background": { "beginsPattern": "Starting compilation\\.\\.\\.", "endsPattern": "Finished compilation with" @@ -76,12 +71,7 @@ "relative", "${workspaceFolder}" ], - "pattern": { - "regexp": "\\] ([^(]+)\\((\\d+,\\d+)\\): (.*)$", - "file": 1, - "location": 2, - "message": 3 - }, + "pattern": "$tsc", "background": { "beginsPattern": "Starting compilation", "endsPattern": "Finished compilation" @@ -275,14 +265,20 @@ }, { "label": "Run and Compile Agents - OSS", - "dependsOn": ["Transpile Client", "Run Dev Agents"], + "dependsOn": [ + "Transpile Client", + "Run Dev Agents" + ], "dependsOrder": "sequence", "inAgents": true, "problemMatcher": [] }, { "label": "Run and Compile Code - OSS", - "dependsOn": ["Transpile Client", "Run Dev"], + "dependsOn": [ + "Transpile Client", + "Run Dev" + ], "dependsOrder": "sequence", "inAgents": true, "problemMatcher": [] diff --git a/build/azure-pipelines/common/waitForArtifacts.ts b/build/azure-pipelines/common/waitForArtifacts.ts index 1b48a70d9944c..7f3be3842db6a 100644 --- a/build/azure-pipelines/common/waitForArtifacts.ts +++ b/build/azure-pipelines/common/waitForArtifacts.ts @@ -16,13 +16,13 @@ async function main(artifacts: string[]): Promise { throw new Error(`Usage: node waitForArtifacts.ts ...`); } - // This loop will run for 30 minutes and waits to the x64 and arm64 artifacts + // This loop will run for 60 minutes and waits to the x64 and arm64 artifacts // to be uploaded to the pipeline by the `macOS` and `macOSARM64` jobs. As soon // as these artifacts are found, the loop completes and the `macOSUnivesrsal` // job resumes. - for (let index = 0; index < 60; index++) { + for (let index = 0; index < 120; index++) { try { - console.log(`Waiting for artifacts (${artifacts.join(', ')}) to be uploaded (${index + 1}/60)...`); + console.log(`Waiting for artifacts (${artifacts.join(', ')}) to be uploaded (${index + 1}/120)...`); const allArtifacts = await retry(() => getPipelineArtifacts()); console.log(` * Artifacts attached to the pipelines: ${allArtifacts.length > 0 ? allArtifacts.map(a => a.name).join(', ') : 'none'}`); @@ -40,7 +40,7 @@ async function main(artifacts: string[]): Promise { await new Promise(c => setTimeout(c, 30_000)); } - throw new Error(`ERROR: Artifacts (${artifacts.join(', ')}) were not uploaded within 30 minutes.`); + throw new Error(`ERROR: Artifacts (${artifacts.join(', ')}) were not uploaded within 60 minutes.`); } main(process.argv.splice(2)).then(() => { diff --git a/build/gulpfile.ts b/build/gulpfile.ts index a3e6a82d5728f..d8c0aad4012d5 100644 --- a/build/gulpfile.ts +++ b/build/gulpfile.ts @@ -13,7 +13,6 @@ import { compileExtensionMediaTask, compileExtensionsTask, watchExtensionsTask } import * as compilation from './lib/compilation.ts'; import * as task from './lib/task.ts'; import * as util from './lib/util.ts'; -import { useEsbuildTranspile } from './buildConfig.ts'; // Extension point names gulp.task(compilation.compileExtensionPointNamesTask); @@ -36,9 +35,7 @@ gulp.task(transpileClientTask); const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compilation.compileApiProposalNamesTask, compilation.compileExtensionPointNamesTask, compilation.compileTask('src', 'out', false))); gulp.task(compileClientTask); -const watchClientTask = useEsbuildTranspile - ? task.define('watch-client', task.parallel(compilation.watchTask('out', false, 'src', { noEmit: true }), compilation.watchApiProposalNamesTask, compilation.watchExtensionPointNamesTask, compilation.watchCodiconsTask)) - : task.define('watch-client', task.series(util.rimraf('out'), task.parallel(compilation.watchTask('out', false), compilation.watchApiProposalNamesTask, compilation.watchExtensionPointNamesTask, compilation.watchCodiconsTask))); +const watchClientTask = task.define('watch-client', task.parallel(compilation.watchTypeCheckTask('src'), compilation.watchApiProposalNamesTask, compilation.watchExtensionPointNamesTask, compilation.watchCodiconsTask)); gulp.task(watchClientTask); // All diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 75ccf64fe7e60..028938bdbfabe 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -38,6 +38,7 @@ import { promisify } from 'util'; import globCallback from 'glob'; import rceditCallback from 'rcedit'; import * as cp from 'child_process'; +import { spawnTsgo } from './lib/tsgo.ts'; const glob = promisify(globCallback); @@ -221,25 +222,6 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, target: }); } -function runTsGoTypeCheck(): Promise { - return new Promise((resolve, reject) => { - const proc = cp.spawn('tsgo', ['--project', 'src/tsconfig.json', '--noEmit', '--skipLibCheck'], { - cwd: root, - stdio: 'inherit', - shell: true - }); - - proc.on('error', reject); - proc.on('close', code => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`tsgo typecheck failed with exit code ${code}`)); - } - }); - }); -} - const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; const isCI = !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY'] || !!process.env['GITHUB_WORKSPACE']; const useCdnSourceMapsForPackagingTasks = isCI; @@ -266,7 +248,7 @@ gulp.task(task.define('core-ci', task.series( compileExtensionMediaBuildTask, writeISODate('out-build'), // Type-check with tsgo (no emit) - task.define('tsgo-typecheck', () => runTsGoTypeCheck()), + task.define('tsgo-typecheck', () => spawnTsgo(path.join(root, 'src', 'tsconfig.json'), { taskName: 'tsgo-typecheck', noEmit: true })), // Transpile individual files to out-build first (for unit tests) task.define('esbuild-out-build', () => runEsbuildTranspile('out-build', false)), // Then bundle for shipping (bundles also write NLS files to out-build) diff --git a/build/lib/compilation.ts b/build/lib/compilation.ts index 047e83eb7ff2b..1a936bd7039a0 100644 --- a/build/lib/compilation.ts +++ b/build/lib/compilation.ts @@ -23,6 +23,7 @@ import watch from './watch/index.ts'; import bom from 'gulp-bom'; import * as tsb from './tsb/index.ts'; import sourcemaps from 'gulp-sourcemaps'; +import { createTsgoStream } from './tsgo.ts'; import { extractExtensionPointNamesFromFile } from './extractExtensionPoints.ts'; @@ -170,24 +171,27 @@ export function compileTask(src: string, out: string, build: boolean, options: { return task; } -export function watchTask(out: string, build: boolean, srcPath: string = 'src', options?: { noEmit?: boolean }): task.StreamTask { - - const task = () => { - const compile = createCompile(srcPath, { build, emitError: false, transpileOnly: false, preserveEnglish: false, noEmit: options?.noEmit }); - - const src = gulp.src(`${srcPath}/**`, { base: srcPath }); - const watchSrc = watch(`${srcPath}/**`, { base: srcPath, readDelay: 200 }); - +export function watchTypeCheckTask(src: string): task.Task { + return task.define(`watch-typecheck-${path.basename(src)}`, () => { + const projectPath = path.join(import.meta.dirname, '../../', src, 'tsconfig.json'); const generator = new MonacoGenerator(true); generator.execute(); - - return watchSrc - .pipe(generator.stream) - .pipe(util.incremental(compile, src, true)) - .pipe(gulp.dest(out)); - }; - task.taskName = `watch-${path.basename(out)}`; - return task; + const watchInput = watch(`${src}/**`, { base: src, readDelay: 200 }); + const tsgoStream = watchInput.pipe(generator.stream).pipe(util.debounce(() => { + const stream = createTsgoStream(projectPath, { taskName: 'watch-client-noEmit', noEmit: true }); + const result = es.through(); + stream.on('end', () => { + result.emit('end'); + }); + stream.on('error', err => { + reporter(err); + fancyLog.error(ansiColors.red('[tsgo] watch-client-noEmit failed')); + result.emit('end'); + }); + return result.pipe(reporter.end(false)); + })); + return tsgoStream; + }); } const REPO_SRC_FOLDER = path.join(import.meta.dirname, '../../src'); diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts index 36d925d43139a..ff42fa6e88e30 100644 --- a/build/lib/tsgo.ts +++ b/build/lib/tsgo.ts @@ -18,15 +18,13 @@ export function spawnTsgo(projectPath: string, config: { taskName: string; noEmi function runReporter(output: string) { const lines = (output || '').split('\n'); const errorLines = lines.filter(line => /error \w+:/.test(line)); - if (errorLines.length > 0) { - fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${errorLines.length} errors.`); - for (const line of errorLines) { - fancyLog(line); - } + fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${errorLines.length} errors.`); + for (const line of errorLines) { + fancyLog(line); } } - const args = ['tsgo', '--project', projectPath, '--pretty', 'false']; + const args = ['tsgo', '--project', projectPath, '--pretty', 'false', '--incremental']; if (config.noEmit) { args.push('--noEmit'); } else { diff --git a/extensions/configuration-editing/.vscodeignore b/extensions/configuration-editing/.vscodeignore index 7c246a3d95f20..3bd814e6cb8f0 100644 --- a/extensions/configuration-editing/.vscodeignore +++ b/extensions/configuration-editing/.vscodeignore @@ -1,6 +1,7 @@ test/** src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild* package-lock.json diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 8525cd4b5cb56..79e48a5d92050 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4652,6 +4652,15 @@ "advanced" ] }, + "github.copilot.chat.cli.remote.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.cli.remote.enabled%", + "tags": [ + "advanced", + "experimental" + ] + }, "github.copilot.chat.searchSubagent.enabled": { "type": "boolean", "default": false, @@ -6166,6 +6175,11 @@ "name": "fleet", "description": "%github.copilot.command.cli.fleet.description%", "when": "false" + }, + { + "name": "remote", + "description": "%github.copilot.command.cli.remote.description%", + "when": "config.github.copilot.chat.cli.remote.enabled" } ], "customAgentTarget": "github-copilot", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 7d7f9d690d313..cf6d9840f86c2 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -416,6 +416,7 @@ "github.copilot.config.cli.thinkingEffort.enabled": "Enable thinking effort for Language Models in Copilot CLI.", "github.copilot.config.cli.sessionControllerForSessionsApp.enabled": "Enable the new session controller API for Sessions App. Requires VS Code reload.", "github.copilot.config.cli.terminalLinks.enabled": "Enable advanced clickable file links in Copilot CLI terminals. Resolves relative paths against session state directories. Requires VS Code reload.", + "github.copilot.config.cli.remote.enabled": "Enable the experimental /remote command for Copilot CLI sessions, allowing you to view and steer sessions from github.com (Mission Control).", "github.copilot.config.backgroundAgent.enabled": "Enable the Copilot CLI. When disabled, the Copilot CLI will not be available in 'Continue In' context menus.", "github.copilot.config.cloudAgent.enabled": "Enable the Cloud Agent. When disabled, the Cloud Agent will not be available in 'Continue In' context menus.", "github.copilot.config.copilotMemory.enabled": "Enable agentic memory for GitHub Copilot. When enabled, Copilot can store repository-scoped facts about your codebase conventions, structure, and preferences remotely on GitHub, and recall them in future conversations to provide more contextually relevant assistance. [Learn more](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/copilot-memory).", @@ -442,6 +443,7 @@ "github.copilot.command.cli.compact.description": "Free up context by compacting the conversation history", "github.copilot.command.cli.plan.description": "Create an implementation plan before coding", "github.copilot.command.cli.fleet.description": "Enable fleet mode for parallel subagent execution", + "github.copilot.command.cli.remote.description": "Enable remote control for this session", "github.copilot.command.claude.sessions.rename": "Rename...", "github.copilot.command.cli.sessions.openRepository": "Open Repository", "github.copilot.command.cli.sessions.openWorktreeInNewWindow": "Open Session in New Window", diff --git a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md index d6a9310881aba..669f49ac5a375 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md +++ b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md @@ -13,6 +13,7 @@ This guide covers the **Claude** session target in VS Code Copilot Chat: what it - [Enabling Claude Sessions](#enabling-claude-sessions) - [Opening a Claude Session](#opening-a-claude-session) - [Choosing a Model](#choosing-a-model) + - [Thinking Effort](#thinking-effort) - [Session Options](#session-options) - [Permission Mode](#permission-mode) - [Folder Selection](#folder-selection) @@ -105,6 +106,18 @@ Your model choice is remembered across sessions. To change models, click the mod > **Default behavior:** If no preference is stored, the latest Sonnet model is selected automatically. +### Thinking Effort + +Some Claude models support configurable **thinking effort** — the amount of reasoning Claude applies before responding. When a model supports this, a **Thinking Effort** dropdown appears in the model picker with options such as: + +| Level | Description | +|-------|-------------| +| **Low** | Faster responses with less reasoning | +| **Medium** | Balanced reasoning and speed | +| **High** | Greater reasoning depth but slower (default when available) | + +Thinking effort is set per request and is not persisted across sessions. + --- ## Session Options diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts index 74794c4637b92..4055fdf98b014 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts @@ -22,6 +22,7 @@ export interface SessionState { capturingToken: CapturingToken | undefined; folderInfo: ClaudeFolderInfo | undefined; usageHandler: UsageHandler | undefined; + reasoningEffort: string | undefined; } /** @@ -91,6 +92,16 @@ export interface IClaudeSessionStateService { * Sets the usage handler for a session. */ setUsageHandlerForSession(sessionId: string, handler: UsageHandler | undefined): void; + + /** + * Gets the reasoning effort for a session (user's per-request selection from the model picker). + */ + getReasoningEffortForSession(sessionId: string): string | undefined; + + /** + * Sets the reasoning effort for a session. + */ + setReasoningEffortForSession(sessionId: string, effort: string | undefined): void; } export const IClaudeSessionStateService = createServiceIdentifier('IClaudeSessionStateService'); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts index 92ab9f60073c1..2b7ca58ce97ed 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as l10n from '@vscode/l10n'; import type * as vscode from 'vscode'; import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider'; import { ILogService } from '../../../../platform/log/common/logService'; @@ -13,6 +14,8 @@ import { Disposable } from '../../../../util/vs/base/common/lifecycle'; import type { ParsedClaudeModelId } from '../common/claudeModelId'; import { tryParseClaudeModelId } from './claudeModelId'; +export const CLAUDE_REASONING_EFFORT_PROPERTY = 'reasoningEffort'; + export interface IClaudeCodeModels { readonly _serviceBrand: undefined; /** @@ -87,6 +90,7 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { multiplier, multiplierNumeric: endpoint.multiplier, isUserSelectable: true, + configurationSchema: buildConfigurationSchema(endpoint), capabilities: { imageInput: endpoint.supportsVision, toolCalling: endpoint.supportsToolCalls, @@ -158,3 +162,37 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { } } } + +const SUPPORTED_EFFORT_LEVELS = ['low', 'medium', 'high'] as const; + +function buildConfigurationSchema(endpoint: IChatEndpoint): vscode.LanguageModelConfigurationSchema | undefined { + const effortLevels = endpoint.supportsReasoningEffort?.filter( + (level): level is typeof SUPPORTED_EFFORT_LEVELS[number] => + (SUPPORTED_EFFORT_LEVELS as readonly string[]).includes(level) + ); + if (!effortLevels || effortLevels.length <= 1) { + return; + } + + const defaultEffort = effortLevels.includes('high') ? 'high' : undefined; + + return { + properties: { + [CLAUDE_REASONING_EFFORT_PROPERTY]: { + type: 'string', + title: l10n.t('Thinking Effort'), + enum: effortLevels, + enumItemLabels: effortLevels.map(level => level.charAt(0).toUpperCase() + level.slice(1)), + enumDescriptions: effortLevels.map(level => { + switch (level) { + case 'low': return l10n.t('Faster responses with less reasoning'); + case 'medium': return l10n.t('Balanced reasoning and speed'); + case 'high': return l10n.t('Greater reasoning depth but slower'); + } + }), + default: defaultEffort, + group: 'navigation', + } + } + }; +} diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts index 617c0b55f830d..0ec3e8b30c163 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts @@ -215,13 +215,14 @@ export class ClaudeLanguageModelServer extends Disposable { } const capturingToken = sessionId ? this.sessionStateService.getCapturingTokenForSession(sessionId) : undefined; + const reasoningEffort = sessionId ? this.sessionStateService.getReasoningEffortForSession(sessionId) : undefined; const doRequest = () => streamingEndpoint.makeChatRequest2({ debugName: 'Claude Copilot Proxy', messages: messagesForLogging, finishedCb: async () => undefined, location: ChatLocation.MessagesProxy, - modelCapabilities: { enableThinking: true }, + modelCapabilities: { enableThinking: true, reasoningEffort }, userInitiatedRequest: isUserInitiatedMessage }, tokenSource.token); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts index 73b3300773a0d..8769c8ebd534e 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts @@ -20,6 +20,9 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess // State for sessions (model and permission mode selections) // TODO: What about expiration of state for old sessions? + // TODO: Refactor setters to use a single `updateSession(id, patch)` method or spread + // pattern (`{ ...existing, field: value }`) so that adding a new field to SessionState + // doesn't require touching every existing setter. private readonly _sessionState = new Map(); constructor() { @@ -42,6 +45,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess capturingToken: existing?.capturingToken, folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, + reasoningEffort: existing?.reasoningEffort, }); this._onDidChangeSessionState.fire({ sessionId, modelId }); } @@ -61,6 +65,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess capturingToken: existing?.capturingToken, folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, + reasoningEffort: existing?.reasoningEffort, }); this._onDidChangeSessionState.fire({ sessionId, permissionMode: mode }); } @@ -77,6 +82,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess capturingToken: token, folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, + reasoningEffort: existing?.reasoningEffort, }); } @@ -95,6 +101,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess capturingToken: existing?.capturingToken, folderInfo, usageHandler: existing?.usageHandler, + reasoningEffort: existing?.reasoningEffort, }); this._onDidChangeSessionState.fire({ sessionId, folderInfo }); } @@ -111,6 +118,26 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess capturingToken: existing?.capturingToken, folderInfo: existing?.folderInfo, usageHandler: handler, + reasoningEffort: existing?.reasoningEffort, + }); + } + + getReasoningEffortForSession(sessionId: string): string | undefined { + return this._sessionState.get(sessionId)?.reasoningEffort; + } + + setReasoningEffortForSession(sessionId: string, effort: string | undefined): void { + const existing = this._sessionState.get(sessionId); + if (existing?.reasoningEffort === effort) { + return; + } + this._sessionState.set(sessionId, { + modelId: existing?.modelId, + permissionMode: existing?.permissionMode ?? 'acceptEdits', + capturingToken: existing?.capturingToken, + folderInfo: existing?.folderInfo, + usageHandler: existing?.usageHandler, + reasoningEffort: effort, }); } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts index 0461a8052d784..035f13740c0bb 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts @@ -25,6 +25,7 @@ function createMockEndpoint(overrides: { multiplier?: number; apiType?: string; modelProvider?: string; + supportsReasoningEffort?: string[]; }): IChatEndpoint { const isAnthropic = overrides.modelProvider === undefined || overrides.modelProvider === 'Anthropic'; return { @@ -41,6 +42,7 @@ function createMockEndpoint(overrides: { supportsToolCalls: true, supportsVision: false, supportsPrediction: false, + supportsReasoningEffort: overrides.supportsReasoningEffort, isDefault: false, isFallback: false, policy: 'enabled', @@ -273,6 +275,53 @@ describe('ClaudeCodeModels', () => { expect(info[0].maxOutputTokens).toBe(endpoint.maxOutputTokens); expect(info[0].version).toBe(endpoint.version); }); + it('includes configurationSchema when endpoint supports multiple reasoning effort levels', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['low', 'medium', 'high'], + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + expect(info[0].configurationSchema).toBeDefined(); + const schema = info[0].configurationSchema!; + expect(schema.properties?.['reasoningEffort']).toBeDefined(); + expect(schema.properties!['reasoningEffort'].enum).toEqual(['low', 'medium', 'high']); + expect(schema.properties!['reasoningEffort'].default).toBe('high'); + }); + + it('omits configurationSchema when endpoint has no reasoning effort support', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + expect(info[0].configurationSchema).toBeUndefined(); + }); + + it('omits configurationSchema when endpoint has only one reasoning effort level', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['high'], + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + expect(info[0].configurationSchema).toBeUndefined(); + }); }); describe('cache invalidation on onDidModelsRefresh', () => { diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts index f21762416fdca..df9e51dd3ca21 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts @@ -197,6 +197,77 @@ describe('ClaudeSessionStateService', () => { }); }); + describe('getReasoningEffortForSession', () => { + it('should return undefined when no reasoning effort is set', () => { + const effort = service.getReasoningEffortForSession('session-1'); + assert.strictEqual(effort, undefined); + }); + + it('should return the set reasoning effort', () => { + service.setReasoningEffortForSession('session-1', 'high'); + const effort = service.getReasoningEffortForSession('session-1'); + assert.strictEqual(effort, 'high'); + }); + + it('should return different efforts for different sessions', () => { + service.setReasoningEffortForSession('session-1', 'high'); + service.setReasoningEffortForSession('session-2', 'low'); + + assert.strictEqual(service.getReasoningEffortForSession('session-1'), 'high'); + assert.strictEqual(service.getReasoningEffortForSession('session-2'), 'low'); + }); + }); + + describe('setReasoningEffortForSession', () => { + it('should allow setting a reasoning effort', () => { + service.setReasoningEffortForSession('session-1', 'medium'); + assert.strictEqual(service.getReasoningEffortForSession('session-1'), 'medium'); + }); + + it('should allow clearing a reasoning effort', () => { + service.setReasoningEffortForSession('session-1', 'high'); + service.setReasoningEffortForSession('session-1', undefined); + assert.strictEqual(service.getReasoningEffortForSession('session-1'), undefined); + }); + + it('should not update state when effort is unchanged', () => { + service.setReasoningEffortForSession('session-1', 'high'); + const stateBefore = service.getReasoningEffortForSession('session-1'); + service.setReasoningEffortForSession('session-1', 'high'); + assert.strictEqual(service.getReasoningEffortForSession('session-1'), stateBefore); + }); + + it('should preserve other state when setting reasoning effort', () => { + service.setModelIdForSession('session-1', OPUS_4); + service.setPermissionModeForSession('session-1', 'bypassPermissions'); + + service.setReasoningEffortForSession('session-1', 'high'); + + assert.strictEqual(service.getModelIdForSession('session-1'), OPUS_4); + assert.strictEqual(service.getPermissionModeForSession('session-1'), 'bypassPermissions'); + }); + + it('should not fire onDidChangeSessionState event', () => { + const events: SessionStateChangeEvent[] = []; + service.onDidChangeSessionState(e => events.push(e)); + + service.setReasoningEffortForSession('session-1', 'high'); + + assert.strictEqual(events.length, 0); + }); + + it('should initialize defaults when session has no prior state', () => { + service.setReasoningEffortForSession('new-session', 'medium'); + + assert.strictEqual(service.getModelIdForSession('new-session'), undefined); + assert.strictEqual(service.getPermissionModeForSession('new-session'), 'acceptEdits'); + assert.strictEqual(service.getCapturingTokenForSession('new-session'), undefined); + assert.strictEqual(service.getFolderInfoForSession('new-session'), undefined); + assert.strictEqual(service.getUsageHandlerForSession('new-session'), undefined); + assert.strictEqual(service.getReasoningEffortForSession('new-session'), 'medium'); + }); + }); + describe('dispose', () => { it('should clear session state on dispose', () => { service.setModelIdForSession('session-1', OPUS_4); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 96f64b577f37c..4129b67b595e8 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -5,16 +5,21 @@ import type { Attachment, SendOptions, Session, SessionOptions } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; +import * as crypto from 'crypto'; import type * as vscode from 'vscode'; import type { ChatParticipantToolToken } from 'vscode'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../../platform/log/common/logService'; +import { IFetcherService } from '../../../../platform/networking/common/fetcherService'; import { GenAiMetrics } from '../../../../platform/otel/common/genAiMetrics'; import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel, resolveWorkspaceOTelMetadata, workspaceMetadataToOTelAttributes } from '../../../../platform/otel/common/index'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IRequestLogger, LoggedRequestKind } from '../../../../platform/requestLogger/common/requestLogger'; import { PromptTokenCategory, PromptTokenLabel } from '../../../../platform/tokenizer/node/promptTokenDetails'; -import { IGitService } from '../../../../platform/git/common/gitService'; +import { IGitService, getGithubRepoIdFromFetchUrl } from '../../../../platform/git/common/gitService'; +import { IGithubRepositoryService, PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService'; +import { MissionControlApiClient, type McEvent } from './missionControlApiClient'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { raceCancellation } from '../../../../util/vs/base/common/async'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; @@ -24,13 +29,14 @@ import { DisposableStore, IDisposable, toDisposable } from '../../../../util/vs/ import { truncate } from '../../../../util/vs/base/common/strings'; import { ThemeIcon } from '../../../../util/vs/base/common/themables'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseMarkdownPart, ChatResponseThinkingProgressPart, ChatSessionStatus, ChatToolInvocationPart, EventEmitter, Uri } from '../../../../vscodeTypes'; +import { ChatResponseMarkdownPart, ChatResponseThinkingProgressPart, ChatSessionStatus, ChatToolInvocationPart, EventEmitter, MarkdownString, Uri } from '../../../../vscodeTypes'; import { IToolsService } from '../../../tools/common/toolsService'; import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore'; import { ExternalEditTracker } from '../../common/externalEditTracker'; import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo'; import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems, clearTodoList } from '../common/copilotCLITools'; import { getCopilotCLISessionDir } from './cliHelpers'; +import { SessionIdForCLI } from '../common/utils'; import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor'; import { ICopilotCLIImageSupport } from './copilotCLIImageSupport'; import { handleExitPlanMode } from './exitPlanModeHandler'; @@ -41,13 +47,60 @@ import { IQuestion, IUserQuestionHandler } from './userInputHelpers'; /** * Known commands that can be sent to a CopilotCLI session instead of a free-form prompt. */ -export type CopilotCLICommand = 'compact' | 'plan' | 'fleet'; +export type CopilotCLICommand = 'compact' | 'plan' | 'fleet' | 'remote'; /** * The set of all known CopilotCLI commands. Used by callers that need to * distinguish a slash-command from a regular prompt at runtime. */ -export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'plan', 'fleet'] as const; +export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'plan', 'fleet', 'remote'] as const; + +/** + * Shared Mission Control state keyed by SDK session ID. + * CopilotCLISession instances are recreated per request, so MC state + * must be stored externally to persist across turns. + */ +interface McSharedState { + mcSessionId: string; + /** HTTP client for the MC session endpoints (handles auth, URL, and fetcher routing). */ + mcApiClient: MissionControlApiClient; + mcEventBuffer: McEvent[]; + mcCompletedCommandIds: string[]; + mcFlushInterval: ReturnType | undefined; + mcPollInterval: ReturnType | undefined; + mcLastEventId: string | null; + /** Reference to the SDK session for steering from the command poller. */ + mcSdkSession: Session; + /** Dispose function for the persistent on('*') listener for MC events. */ + mcEventListenerDispose: (() => void) | undefined; + /** VS Code session resource URI for routing steering through the chat UI. */ + mcSessionResource: import('vscode').Uri; +} +const mcStateBySessionId = new Map(); + +/** + * Stop intervals, detach the persistent event listener, and clear sensitive + * fields on a Mission Control shared state. Safe to call multiple times. + */ +function cleanupMcSharedState(state: McSharedState): void { + if (state.mcFlushInterval) { + clearInterval(state.mcFlushInterval); + state.mcFlushInterval = undefined; + } + if (state.mcPollInterval) { + clearInterval(state.mcPollInterval); + state.mcPollInterval = undefined; + } + if (state.mcEventListenerDispose) { + state.mcEventListenerDispose(); + state.mcEventListenerDispose = undefined; + } + // Release buffered events for GC. The API client captures no session-scoped + // credentials — tokens are fetched per-request — so there is nothing further + // to clear. + state.mcEventBuffer = []; + state.mcCompletedCommandIds = []; +} export const builtinSlashSCommands = { commit: '/commit', @@ -139,6 +192,11 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private _bridgeProcessor: CopilotCliBridgeSpanProcessor | undefined; private readonly _todoSqlQuery = new TodoSqlQuery(); + /** Get or create shared MC state for this SDK session. */ + private get _mcState(): McSharedState | undefined { + return mcStateBySessionId.get(this.sessionId); + } + /** Callback to propagate trace context to the SDK's OtelLifecycle. */ private _updateSdkTraceContext: ((traceparent?: string, tracestate?: string) => void) | undefined; public get pendingPrompt(): string | undefined { @@ -168,6 +226,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes @IConfigurationService private readonly configurationService: IConfigurationService, @IOTelService private readonly _otelService: IOTelService, @IGitService private readonly _gitService: IGitService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IGithubRepositoryService private readonly _githubRepositoryService: IGithubRepositoryService, + @IFetcherService private readonly _fetcherService: IFetcherService, ) { super(); this.sessionId = _sdkSession.sessionId; @@ -439,6 +500,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const shouldHandleExitPlanModeRequests = this.configurationService.getConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled); disposables.add(toDisposable(this._sdkSession.on('*', (event) => { this.logService.trace(`[CopilotCLISession] CopilotCLI Event: ${JSON.stringify(event, null, 2)}`); + // Forward events to Mission Control if remote control is active + this._bufferMcEvent(event); }))); disposables.add(toDisposable(this._sdkSession.on('permission.requested', async (event) => { const permissionRequest = event.data.permissionRequest; @@ -878,6 +941,18 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._stream?.progress(l10n.t('Compacting conversation...')); await this._sdkSession.initializeAndValidateTools(); this._sdkSession.currentMode = 'interactive'; + // Mirror the Copilot CLI SDK's own `messages.length < 2` guard to + // avoid its "Nothing to compact." throw, while distinguishing + // empty sessions from already-compacted sessions in the UI. + const messages = await this._sdkSession.getChatMessages(); + if (messages.length === 0) { + this._stream?.markdown(l10n.t('Nothing to compact.')); + break; + } + if (messages.length < 2) { + this._stream?.markdown(l10n.t('Conversation already compacted.')); + break; + } const result = await this._sdkSession.compactHistory(); if (result.success) { this._stream?.markdown(l10n.t('Compacted conversation.')); @@ -890,6 +965,10 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes await this._startFleetAndWaitForIdle(input); break; } + case 'remote': { + await this._handleRemoteControl(input); + break; + } } } else { if ('command' in input && input.command === 'plan') { @@ -932,6 +1011,519 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } } + /** + * Handle `/remote` command — enables or disables Mission Control remote + * control for this session by calling the Copilot API directly. + */ + private async _handleRemoteControl(input: CopilotCLISessionInput): Promise { + if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIRemoteEnabled)) { + this._stream?.markdown(l10n.t('The /remote command is experimental and not enabled. Set `github.copilot.chat.cli.remote.enabled` to `true` in settings to use it.')); + return; + } + + const args = ('prompt' in input ? input.prompt : '')?.trim().toLowerCase(); + const isCurrentlyActive = !!this._mcState; + const enable = args === 'off' ? false : (args === 'on' ? true : !isCurrentlyActive); + + try { + if (!enable) { + await this._teardownRemoteControl(); + this._stream?.markdown(l10n.t('Remote control disabled.')); + return; + } + + this._stream?.progress(l10n.t('Enabling remote control...')); + + // Step 1: Resolve git context (owner/repo). Do this before any auth + // work so we can fail fast on non-GitHub workspaces without prompting + // the user for permissive GitHub scopes unnecessarily. + const workingDir = getWorkingDirectory(this._workspaceInfo); + if (!workingDir) { + this._stream?.markdown(l10n.t('Unable to enable remote control: no workspace folder found.')); + return; + } + + const nwo = await this._resolveGitHubNwo(workingDir); + if (!nwo) { + this._stream?.markdown(l10n.t('Unable to enable remote control: this workspace is not a GitHub repository.')); + return; + } + + // Step 2: Resolve numeric owner/repo IDs via GitHub API. Routed + // through `IGithubRepositoryService` so it hits the correct API host + // (github.com or a GHES instance) with consistent proxy/telemetry. + let repoData: { id: number; owner: { id: number } }; + try { + repoData = await this._githubRepositoryService.getRepositoryInfo(nwo.owner, nwo.repo); + } catch (err) { + this.logService.warn(`[CopilotCLISession] Failed to resolve repository ${nwo.owner}/${nwo.repo}: ${err}`); + this._stream?.markdown(l10n.t('Unable to enable remote control: could not resolve repository {0}/{1}.', nwo.owner, nwo.repo)); + return; + } + + // Step 3: Create the Mission Control session through the dedicated + // API client. The client handles permissive auth acquisition (with + // an interactive sign-in prompt), GHES-aware URL resolution, and + // `IFetcherService` routing for proxy/CA/telemetry correctness. + const mcApiClient = new MissionControlApiClient(this._authenticationService, this._fetcherService, this.logService); + const agentTaskId = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; + let createResult: { id: string; taskId: string }; + try { + createResult = await mcApiClient.createSession(repoData.owner.id, repoData.id, agentTaskId, { + createIfNone: { detail: l10n.t('Sign in to GitHub to enable remote control for this session.') }, + }); + } catch (err) { + if (err instanceof PermissiveAuthRequiredError) { + this._stream?.markdown(l10n.t('Unable to enable remote control: additional GitHub permissions are required. Please sign in again to grant access.')); + return; + } + this.logService.error(`[CopilotCLISession] MC session creation failed: ${err}`); + this._stream?.markdown(l10n.t('Unable to enable remote control: {0}', err instanceof Error ? err.message : String(err))); + return; + } + + // Step 4: Store MC state in the shared map (keyed by SDK session ID) + // so it persists across CopilotCLISession instances. If a prior MC + // state exists (e.g. /remote invoked twice, or re-enabled after a + // partial failure), tear down its listeners/intervals before replacing + // it to avoid orphaned setInterval tasks and on('*') handlers. + const existingSharedState = mcStateBySessionId.get(this.sessionId); + if (existingSharedState) { + cleanupMcSharedState(existingSharedState); + } + const sharedState: McSharedState = { + mcSessionId: createResult.id, + mcApiClient, + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcSdkSession: this._sdkSession, + mcEventListenerDispose: undefined, + mcSessionResource: SessionIdForCLI.getResource(this.sessionId), + }; + mcStateBySessionId.set(this.sessionId, sharedState); + this.logService.info(`[CopilotCLISession] Set shared MC state for session ${this.sessionId}, mcSessionId=${createResult.id}`); + + // Tie shared-state cleanup to the SDK session's lifecycle. If the + // session ends (or errors out) without an explicit `/remote off`, + // we must still stop intervals, detach the persistent listener, + // and drop the cached GitHub token — otherwise the module-level + // map and background timers outlive the session. + const cleanupSessionIdCapture = this.sessionId; + const cleanupLogService = this.logService; + const cleanupOnShutdown = () => { + const state = mcStateBySessionId.get(cleanupSessionIdCapture); + if (!state || state.mcSdkSession !== sharedState.mcSdkSession) { + return; + } + cleanupLogService.info(`[CopilotCLISession] SDK session ended — cleaning up MC state for ${cleanupSessionIdCapture}`); + cleanupMcSharedState(state); + mcStateBySessionId.delete(cleanupSessionIdCapture); + }; + const disposeOnSessionShutdown = this._sdkSession.on('session.shutdown', cleanupOnShutdown); + const disposeOnSessionError = this._sdkSession.on('session.error', cleanupOnShutdown); + + // Step 7: Send the initial session.start event — MC requires this to + // transition out of "Fueling the runtime engines..." loading state. + const sessionStartEvent = this._createMcEvent('session.start', { + sessionId: sharedState.mcSessionId, + version: 1, + producer: 'copilot-developer-cli', + copilotVersion: '1.0.0', + startTime: new Date().toISOString(), + remoteSteerable: true, + context: { + cwd: workingDir, + gitRoot: workingDir, + repository: `${nwo.owner}/${nwo.repo}`, + }, + }); + sharedState.mcEventBuffer.push(sessionStartEvent); + + // Also send a session.remote_steerable_changed event to explicitly + // enable steering on the MC web UI. + sharedState.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', { + remoteSteerable: true, + })); + + // Step 7b: Replay existing conversation history so the MC web UI + // shows all messages that occurred before /remote was invoked. + // Only replay conversation-content events — skip session lifecycle + // events that would override the remoteSteerable state we just set. + const replayableTypes = new Set([ + 'user.message', 'assistant.message', 'assistant.turn_start', + 'assistant.turn_complete', 'tool.execution_start', + 'tool.execution_complete', + ]); + const existingEvents = this._sdkSession.getEvents(); + let replayed = 0; + for (const event of existingEvents) { + const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null }; + if (e.type && replayableTypes.has(e.type)) { + this._bufferMcEvent(e); + replayed++; + } + } + this.logService.info(`[CopilotCLISession] Replayed ${replayed}/${existingEvents.length} existing events to MC`); + + await this._flushMcEvents(); + + // Step 7c: Register a persistent on('*') listener on the SDK session + // so that events emitted between requests (e.g. from MC steering sends) + // are captured and forwarded to MC. Per-request listeners are disposed + // after each request completes, so this persistent listener fills the gap. + const sessionId = this.sessionId; + const disposePersistentEventListener = this._sdkSession.on('*', (event) => { + const state = mcStateBySessionId.get(sessionId); + if (!state) { return; } + // Use the static helper instead of this._bufferMcEvent to avoid + // relying on the instance that started MC (it may be stale). + const eventType = (event as { type?: string }).type ?? 'unknown'; + if ( + eventType === 'assistant.message_delta' || + eventType === 'assistant.streaming_delta' || + eventType === 'session.idle' || + eventType === 'session.shutdown' || + eventType === 'session.error' || + eventType === 'session.usage_info' || + eventType === 'assistant.usage' || + eventType === 'session.title_changed' || + eventType === 'pending_messages.modified' || + eventType === 'session.mcp_server_status_changed' || + eventType === 'session.mcp_servers_loaded' || + eventType === 'session.skills_loaded' || + eventType === 'session.tools_updated' + ) { + return; + } + const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null }; + if (e.id && e.timestamp) { + state.mcEventBuffer.push({ + id: e.id, + timestamp: e.timestamp, + parentId: e.parentId ?? state.mcLastEventId ?? null, + type: eventType, + data: (e.data ?? {}) as Record, + }); + state.mcLastEventId = e.id; + } else { + const id = crypto.randomUUID(); + state.mcEventBuffer.push({ + id, + timestamp: new Date().toISOString(), + parentId: state.mcLastEventId ?? null, + type: eventType, + data: (e.data ?? {}) as Record, + }); + state.mcLastEventId = id; + } + }); + + // Combine all three SDK listener disposers so `cleanupMcSharedState` + // (via `mcEventListenerDispose`) tears them all down in one step — + // on `/remote off`, SDK session shutdown/error, or replacement. + sharedState.mcEventListenerDispose = () => { + disposePersistentEventListener(); + disposeOnSessionShutdown(); + disposeOnSessionError(); + }; + + // Step 8: Construct and display the frontend URL. Use the host from + // the resolved repo so GHES/GHE.com repositories open on the correct + // domain rather than always linking to github.com. + const frontendUrl = `https://${nwo.host}/${nwo.owner}/${nwo.repo}/tasks/${createResult.taskId}`; + this.logService.info(`[CopilotCLISession] MC session created, URL: ${frontendUrl}`); + + // Render a persistent inline info banner using the proposed + // `stream.info()` API (blue background + blue info icon, matches + // the native chat info notification style). The button uses + // `vscode.open` so it opens the URL externally without invoking + // the model, and the banner stays visible after click. + const banner = new MarkdownString( + l10n.t('**Remote control is enabled.** You can open this session from any device.') + ); + this._stream?.info(banner); + this._stream?.button({ + command: 'vscode.open', + arguments: [Uri.parse(frontendUrl)], + title: l10n.t('Open on GitHub'), + }); + + // Step 9: Start continuous event exporter and command poller + this._startMcEventExporter(); + this._startMcCommandPoller(); + } catch (error) { + this.logService.error(`[CopilotCLISession] Remote control error: ${error}`); + this._stream?.markdown(l10n.t('Unable to enable remote control: {0}', error instanceof Error ? error.message : String(error))); + } + } + + /** + * Tear down an active Mission Control session. + */ + private async _teardownRemoteControl(): Promise { + const state = this._mcState; + if (!state) { + this.logService.info('[CopilotCLISession] No active MC session to tear down'); + return; + } + + const mcSessionId = state.mcSessionId; + const mcApiClient = state.mcApiClient; + cleanupMcSharedState(state); + mcStateBySessionId.delete(this.sessionId); + this.logService.info(`[CopilotCLISession] Tearing down MC session ${mcSessionId}`); + + // Best-effort server-side teardown; the client swallows its own errors. + await mcApiClient.deleteSession(mcSessionId); + } + + /** + * Resolve owner/repo for a working directory using `IGitService`, which + * handles non-`origin` remotes, SSH aliases, and GitHub Enterprise hosts + * via the shared parsing utilities. + */ + private async _resolveGitHubNwo(workingDirectory: vscode.Uri): Promise<{ owner: string; repo: string; host: string } | undefined> { + const fetchInfo = await this._gitService.getRepositoryFetchUrls(workingDirectory); + if (!fetchInfo?.remoteFetchUrls) { + return undefined; + } + for (const fetchUrl of fetchInfo.remoteFetchUrls) { + if (!fetchUrl) { + continue; + } + const repoId = getGithubRepoIdFromFetchUrl(fetchUrl); + if (repoId) { + return { owner: repoId.org, repo: repoId.repo, host: repoId.host }; + } + } + return undefined; + } + + // ── Mission Control event exporter ─────────────────────────────────── + + /** + * Start listening to SDK events and flushing them to Mission Control. + * Events are batched and sent every 500ms. + */ + private _startMcEventExporter(): void { + this._stopMcEventExporter(); + const state = this._mcState; + if (!state) { return; } + + // Event buffering is handled by _bufferMcEvent(), which is called from + // the per-send on('*') handler. We only need the flush interval here. + state.mcFlushInterval = setInterval(() => { + this._flushMcEvents().catch(err => { + this.logService.warn(`[CopilotCLISession] MC event flush failed: ${err}`); + }); + }, 500); + + this.logService.info('[CopilotCLISession] MC event exporter started'); + } + + /** Stop the MC event exporter. */ + private _stopMcEventExporter(): void { + const state = this._mcState; + if (state?.mcFlushInterval) { + clearInterval(state.mcFlushInterval); + state.mcFlushInterval = undefined; + } + if (state) { + state.mcEventBuffer.length = 0; + } + } + + /** + * Buffer an SDK event for Mission Control. Called from the per-send + * on('*') handler so that events are captured on every turn. + */ + private _bufferMcEvent(event: { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null }): void { + const state = this._mcState; + const eventType = event.type ?? 'unknown'; + if (!state) { + return; + } + // If a persistent MC listener is active, it already buffers every + // SDK event — skip here to avoid duplicating events in the buffer. + if (state.mcEventListenerDispose) { + return; + } + // Skip events that should not be forwarded to MC + if ( + eventType === 'assistant.message_delta' || + eventType === 'assistant.streaming_delta' || + eventType === 'session.idle' || + eventType === 'session.shutdown' || + eventType === 'session.error' || + eventType === 'session.usage_info' || + eventType === 'assistant.usage' || + eventType === 'session.title_changed' || + eventType === 'pending_messages.modified' || + eventType === 'session.mcp_server_status_changed' || + eventType === 'session.mcp_servers_loaded' || + eventType === 'session.skills_loaded' || + eventType === 'session.tools_updated' + ) { + return; + } + this.logService.trace(`[CopilotCLISession] MC buffered event: ${eventType}`); + + // If the SDK event already has a UUID id, pass it through directly + // to preserve the event identity chain. Otherwise create a new event. + if (event.id && event.timestamp) { + const mcEvent: McEvent = { + id: event.id, + timestamp: event.timestamp, + parentId: event.parentId ?? state.mcLastEventId ?? null, + type: eventType, + data: (event.data ?? {}) as Record, + }; + state.mcLastEventId = event.id; + state.mcEventBuffer.push(mcEvent); + } else { + state.mcEventBuffer.push(this._createMcEvent(eventType, (event.data ?? {}) as Record)); + } + } + + /** Create an MC event with a UUID v4 ID and parentId chain. */ + private _createMcEvent(type: string, data: Record): McEvent { + const state = this._mcState; + const id = crypto.randomUUID(); + const event: McEvent = { + id, + timestamp: new Date().toISOString(), + parentId: state?.mcLastEventId ?? null, + type, + data, + }; + if (state) { + state.mcLastEventId = id; + } + return event; + } + + /** + * Flush buffered events to the Mission Control API. + */ + private async _flushMcEvents(): Promise { + const state = this._mcState; + if (!state || !state.mcSessionId) { + return; + } + // Flush when there is anything to send — either new events, or + // completed command IDs that need to be acknowledged. Returning + // early on empty events would strand acks and cause MC to keep + // re-delivering the same in-progress commands. + if (state.mcEventBuffer.length === 0 && state.mcCompletedCommandIds.length === 0) { + return; + } + + const events = state.mcEventBuffer.splice(0, 500); + const completedCommandIds = state.mcCompletedCommandIds.splice(0); + + const eventTypes = events.map(e => e.type).join(', '); + this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]`); + + const ok = await state.mcApiClient.submitEvents(state.mcSessionId, events, completedCommandIds); + if (ok) { + return; + } + + // Re-queue events and completed command IDs on failure so the next attempt + // retries them. Trim after re-queueing so a persistently failing endpoint + // cannot grow the buffers beyond the intended cap. + const MAX_BUFFER = 2000; + state.mcEventBuffer.unshift(...events); + if (state.mcEventBuffer.length > MAX_BUFFER) { + state.mcEventBuffer.splice(0, state.mcEventBuffer.length - MAX_BUFFER); + } + state.mcCompletedCommandIds.unshift(...completedCommandIds); + if (state.mcCompletedCommandIds.length > MAX_BUFFER) { + state.mcCompletedCommandIds.splice(0, state.mcCompletedCommandIds.length - MAX_BUFFER); + } + } + + // ── Mission Control command poller ─────────────────────────────────── + + /** + * Start polling Mission Control for steering commands from the web UI. + * Polls every 3 seconds. + */ + private _startMcCommandPoller(): void { + this._stopMcCommandPoller(); + const state = this._mcState; + if (!state) { return; } + + // Capture sessionId for use in the closure — avoid relying on `this` + // which may be a stale CopilotCLISession instance. + const sessionId = this.sessionId; + const logService = this.logService; + + state.mcPollInterval = setInterval(() => { + const currentState = mcStateBySessionId.get(sessionId); + if (!currentState || !currentState.mcSessionId) { + return; + } + CopilotCLISession._pollMcCommandsStatic(currentState, logService).catch(err => { + logService.warn(`[CopilotCLISession] MC command poll failed: ${err}`); + }); + }, 3000); + + this.logService.info('[CopilotCLISession] MC command poller started'); + } + + /** Stop the MC command poller. */ + private _stopMcCommandPoller(): void { + const state = this._mcState; + if (state?.mcPollInterval) { + clearInterval(state.mcPollInterval); + state.mcPollInterval = undefined; + } + } + + /** + * Poll Mission Control for pending commands and process them. + * Static method to avoid capturing a stale `this` reference. + */ + private static async _pollMcCommandsStatic(state: McSharedState, logService: { info(msg: string): void; warn(msg: string): void }): Promise { + const commands = await state.mcApiClient.getPendingCommands(state.mcSessionId); + + for (const cmd of commands) { + if (cmd.state !== 'in_progress') { + continue; + } + logService.info(`[CopilotCLISession] Processing MC command: ${cmd.type ?? 'user_message'} (${cmd.id})`); + + switch (cmd.type) { + case 'abort': + state.mcSdkSession.abort(); + break; + case 'user_message': + default: { + // Route steering messages through the VS Code chat UI so + // they appear in the chat panel with proper rendering. + const vsCodeApi = require('vscode') as typeof import('vscode'); + vsCodeApi.commands.executeCommand( + 'workbench.action.chat.openSessionWithPrompt.copilotcli', + { + resource: state.mcSessionResource, + prompt: cmd.content, + } + ).then(undefined, err => { + logService.warn(`[CopilotCLISession] MC steering send failed: ${err}`); + }); + break; + } + } + + // Mark command as processed + state.mcCompletedCommandIds.push(cmd.id); + } + } + addUserMessage(content: string) { this._sdkSession.emit('user.message', { content }); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts new file mode 100644 index 0000000000000..7847544af9fc7 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IFetcherService } from '../../../../platform/networking/common/fetcherService'; + +/** Integration id for requests originating from the Copilot CLI /remote feature. */ +const INTEGRATION_ID = 'copilot-developer-cli'; + +/** Base path for Mission Control (agent session) endpoints. */ +const SESSIONS_PATH = '/agents/sessions'; + +/** Per-request timeout (ms). */ +const REQUEST_TIMEOUT_MS = 10_000; + +/** Event payload forwarded to Mission Control. */ +export interface McEvent { + id: string; + timestamp: string; + parentId: string | null; + type: string; + data: Record; +} + +/** Steering command returned from Mission Control. */ +export interface McCommand { + id: string; + content: string; + type?: string; + state: string; +} + +/** Result of a session creation call. */ +export interface McSessionCreateResult { + id: string; + taskId: string; +} + +/** + * Authentication options for Mission Control requests. + * + * Mission Control endpoints write to the `/agents/sessions` API family, which + * requires a permissive GitHub token (same scope as the Copilot Coding Agent + * job dispatch endpoint). Callers pass `createIfNone` to surface an interactive + * sign-in prompt when no permissive session is available. + */ +export interface McAuthOptions { + /** If provided, shows an interactive permission-upgrade prompt when no silent session exists. */ + readonly createIfNone?: { readonly detail: string }; +} + +/** + * HTTP client for the Mission Control agent-session API. + * + * Wraps the four endpoints used by the Copilot CLI `/remote` command: + * - `POST /agents/sessions` — create session + * - `POST /agents/sessions/{id}/events` — submit events (+ ack completed commands) + * - `GET /agents/sessions/{id}/commands` — poll for steering commands + * - `DELETE /agents/sessions/{id}` — tear down session + * + * All requests are routed through {@link IFetcherService} so proxy, custom CA, + * and telemetry configuration are applied consistently. Authentication uses a + * permissive GitHub session (fetched on each call to pick up token refreshes); + * when no permissive session exists and interactive prompting is disabled the + * call throws {@link PermissiveAuthRequiredError}, mirroring the pattern used + * by `IOctoKitService.postCopilotAgentJob`. + */ +export class MissionControlApiClient { + + constructor( + private readonly _authService: IAuthenticationService, + private readonly _fetcherService: IFetcherService, + private readonly _logService: ILogService, + ) { } + + /** + * Create a Mission Control session for the given repo and agent task id. + * + * @throws {PermissiveAuthRequiredError} if `createIfNone` is not set and no silent permissive session exists. + */ + async createSession( + ownerId: number, + repoId: number, + agentTaskId: string, + authOptions: McAuthOptions, + ): Promise { + const { url, headers } = await this._buildRequest(SESSIONS_PATH, authOptions); + const res = await this._fetcherService.fetch(url, { + callSite: 'copilotcli.mc.createSession', + method: 'POST', + headers, + json: { + owner_id: ownerId, + repo_id: repoId, + agent_task_id: agentTaskId, + }, + timeout: REQUEST_TIMEOUT_MS, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Mission Control session creation failed: ${res.status} ${res.statusText} - ${body}`); + } + const data = await res.json() as { id: string; task_id?: string }; + return { id: data.id, taskId: data.task_id ?? agentTaskId }; + } + + /** + * Submit a batch of events to a Mission Control session, optionally + * acknowledging completed steering command ids in the same request. + * + * Returns `true` on success; logs and returns `false` on failure so the + * caller can re-queue events. + */ + async submitEvents( + sessionId: string, + events: readonly McEvent[], + completedCommandIds: readonly string[], + ): Promise { + try { + const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}/events`, {}); + const res = await this._fetcherService.fetch(url, { + callSite: 'copilotcli.mc.submitEvents', + method: 'POST', + headers, + json: { + events, + completed_command_ids: completedCommandIds.length > 0 ? completedCommandIds : undefined, + }, + timeout: REQUEST_TIMEOUT_MS, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + this._logService.warn(`[MissionControlApiClient] submitEvents failed: ${res.status} ${res.statusText} - ${body}`); + return false; + } + return true; + } catch (err) { + this._logService.warn(`[MissionControlApiClient] submitEvents error: ${err}`); + return false; + } + } + + /** + * Poll for pending steering commands. Returns an empty array if the + * endpoint is unreachable or returns a non-OK response. + */ + async getPendingCommands(sessionId: string): Promise { + try { + const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}/commands`, {}); + const res = await this._fetcherService.fetch(url, { + callSite: 'copilotcli.mc.getPendingCommands', + method: 'GET', + headers, + timeout: REQUEST_TIMEOUT_MS, + }); + if (!res.ok) { + return []; + } + const data = await res.json() as { commands?: McCommand[] }; + return data.commands ?? []; + } catch { + return []; + } + } + + /** + * Tear down a Mission Control session. Best-effort: failures are swallowed + * so `/remote off` always completes locally even if the server is unreachable. + */ + async deleteSession(sessionId: string): Promise { + try { + const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}`, {}); + await this._fetcherService.fetch(url, { + callSite: 'copilotcli.mc.deleteSession', + // FetchOptions.method is typed narrowly (GET/POST/PUT) but the + // underlying fetcher forwards any string, so DELETE works at runtime. + method: 'DELETE' as 'POST', + headers, + timeout: REQUEST_TIMEOUT_MS, + }); + } catch (err) { + this._logService.warn(`[MissionControlApiClient] deleteSession error: ${err}`); + } + } + + /** + * Build the absolute URL and auth headers for an MC request. + * + * Auth strategy follows `IOctoKitService.postCopilotAgentJob`: request a + * permissive GitHub session, optionally with an interactive upgrade prompt. + * If no session is available and prompting is disabled, throw + * {@link PermissiveAuthRequiredError} so callers can render a dedicated UX. + * + * The base URL is resolved via the Copilot token's `endpoints.api` which is + * GHES-aware. + */ + private async _buildRequest( + path: string, + authOptions: McAuthOptions, + ): Promise<{ url: string; headers: Record }> { + const session = authOptions.createIfNone + ? await this._authService.getGitHubSession('permissive', { createIfNone: authOptions.createIfNone }) + : await this._authService.getGitHubSession('permissive', { silent: true }); + if (!session?.accessToken) { + throw new PermissiveAuthRequiredError(); + } + + const copilotToken = await this._authService.getCopilotToken(); + const baseUrl = copilotToken.endpoints?.api; + if (!baseUrl) { + throw new Error('Copilot API endpoint is not available'); + } + + const url = `${baseUrl.replace(/\/+$/, '')}${path}`; + const headers: Record = { + 'Authorization': `Bearer ${session.accessToken}`, + 'Copilot-Integration-Id': INTEGRATION_ID, + }; + return { url, headers }; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index f41992be205e9..f6965ebe5d502 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -15,6 +15,8 @@ import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/com import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService'; import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService'; +import { IGithubRepositoryService } from '../../../../../platform/github/common/githubService'; +import { IFetcherService } from '../../../../../platform/networking/common/fetcherService'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; import { ILogService } from '../../../../../platform/log/common/logService'; import { NullMcpService } from '../../../../../platform/mcp/common/mcpService'; @@ -148,7 +150,7 @@ describe('CopilotCLISessionService', () => { } }(); } - return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService())); + return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), authService, new class extends mock() { }(), new class extends mock() { }())); } } as unknown as IInstantiationService; const configurationService = accessor.get(IConfigurationService); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 010be09de1a06..36d5d54ce72ce 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -30,6 +30,10 @@ import { PermissionRequest } from '../permissionHelpers'; import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers'; import { NullICopilotCLIImageSupport } from './testHelpers'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; +import { MockAuthenticationService } from '../../../../../platform/ignore/node/test/mockAuthenticationService'; +import { IGithubRepositoryService } from '../../../../../platform/github/common/githubService'; +import { IFetcherService } from '../../../../../platform/networking/common/fetcherService'; +import { mock } from '../../../../../util/common/test/simpleMock'; vi.mock('../cliHelpers', async (importOriginal) => ({ ...(await importOriginal()), @@ -120,6 +124,9 @@ class MockSdkSession { async compactHistory() { return { success: true }; } + public chatMessages: Awaited> = [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]; + async getChatMessages() { return this.chatMessages; } + async abort() { } isAbortable(): boolean { return true; } @@ -247,7 +254,10 @@ describe('CopilotCLISession', () => { new FakeUserQuestionHandler(), configurationService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), - new MockGitService() + new MockGitService(), + new MockAuthenticationService(), + new class extends mock() { }(), + new class extends mock() { }() )); } @@ -723,6 +733,36 @@ describe('CopilotCLISession', () => { expect(sdkSession.currentMode).toBe('interactive'); expect(stream.output.join('\n')).toContain('Compacted conversation.'); }); + + it('reports already-compacted when no new messages since last compaction (issue #311422)', async () => { + const session = await createSession(); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + // Simulate post-compaction state: only the single summary message remains. + sdkSession.chatMessages = [{ role: 'system', content: 'summary' }]; + let compactCalled = false; + sdkSession.compactHistory = async () => { compactCalled = true; return { success: true }; }; + + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { command: 'compact', prompt: '' }, [], undefined, authInfo, CancellationToken.None); + + expect(compactCalled).toBe(false); + expect(stream.output.join('\n')).toContain('Conversation already compacted.'); + }); + + it('reports nothing-to-compact on an empty session', async () => { + const session = await createSession(); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + // Simulate a brand-new session with no conversation yet. + sdkSession.chatMessages = []; + let compactCalled = false; + sdkSession.compactHistory = async () => { compactCalled = true; return { success: true }; }; + + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { command: 'compact', prompt: '' }, [], undefined, authInfo, CancellationToken.None); + + expect(compactCalled).toBe(false); + expect(stream.output.join('\n')).toContain('Nothing to compact.'); + }); }); describe('steering (sending messages to a busy session)', () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 233ae18e424f1..4eaa05a8f05f9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -21,6 +21,7 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent'; +import { CLAUDE_REASONING_EFFORT_PROPERTY } from '../claude/node/claudeCodeModels'; import { IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService'; import { parseClaudeModelId } from '../claude/node/claudeModelId'; import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService'; @@ -46,6 +47,11 @@ interface InputStateReactivePipeline { readonly store: DisposableStore; } +function getSelectedFolderUri(inputState: vscode.ChatSessionInputState | undefined): URI | undefined { + const selectedFolderId = inputState?.groups.find(group => group.id === FOLDER_OPTION_ID)?.selected?.id; + return selectedFolderId ? URI.file(selectedFolderId) : undefined; +} + export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider { private readonly _controller: ClaudeChatSessionItemController; @@ -109,8 +115,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco throw new Error(`Permission mode not set for session ${effectiveSessionId}`); } const permissionMode = selectedPermissionId; - const selectedFolderId = chatSessionContext.inputState.groups.find(group => group.id === FOLDER_OPTION_ID)?.selected?.id; - const selectedFolderUri = selectedFolderId ? URI.file(selectedFolderId) : undefined; + const selectedFolderUri = getSelectedFolderUri(chatSessionContext.inputState); const folderInfo = await this._controller.getFolderInfoForSession(effectiveSessionId, selectedFolderUri); // Commit UI state to session state service before invoking agent manager @@ -118,6 +123,10 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco this.sessionStateService.setPermissionModeForSession(effectiveSessionId, permissionMode); this.sessionStateService.setFolderInfoForSession(effectiveSessionId, folderInfo); + const rawReasoningEffort = request.modelConfiguration?.[CLAUDE_REASONING_EFFORT_PROPERTY]; + const normalizedReasoningEffort = typeof rawReasoningEffort === 'string' ? rawReasoningEffort.trim() || undefined : undefined; + this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, normalizedReasoningEffort); + // Set usage handler to report token usage for context window widget this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, (usage) => { stream.usage(usage); @@ -242,6 +251,14 @@ export class ClaudeChatSessionItemController extends Disposable { ); item.iconPath = new vscode.ThemeIcon('claude'); item.timing = { created: Date.now() }; + + // Set workspace metadata for correct session grouping + const selectedFolderUri = getSelectedFolderUri(context.inputState); + const folderInfo = await this.getFolderInfoForSession(newSessionId, selectedFolderUri); + if (folderInfo.cwd) { + item.metadata = { workingDirectoryPath: folderInfo.cwd }; + } + this._inProgressItems.set(newSessionId, item); return item; }; @@ -280,6 +297,10 @@ export class ClaudeChatSessionItemController extends Disposable { const newItem = this._controller.createChatSessionItem(ClaudeSessionUri.forSessionId(result.sessionId), title); newItem.iconPath = new vscode.ThemeIcon('claude'); newItem.timing = { created: Date.now() }; + // FYI, dropping any other metadata fields here... + if (item?.metadata?.workingDirectoryPath) { + newItem.metadata = { workingDirectoryPath: item.metadata.workingDirectoryPath }; + } // Copy parent session state to the forked session const parentSessionId = ClaudeSessionUri.getSessionId(sessionResource); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts index 87b5d2d250c43..de9e147b44b53 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts @@ -166,13 +166,13 @@ export class ClaudeSessionOptionBuilder { */ export function buildPermissionModeItems(bypassEnabled: boolean): vscode.ChatSessionProviderOptionGroup { const items: vscode.ChatSessionProviderOptionItem[] = [ - { id: 'default', name: l10n.t('Ask before edits') }, - { id: 'acceptEdits', name: l10n.t('Edit automatically') }, - { id: 'plan', name: l10n.t('Plan mode') }, + { id: 'default', name: l10n.t('Ask before edits'), slashCommand: 'ask' }, + { id: 'acceptEdits', name: l10n.t('Edit automatically'), slashCommand: 'edit' }, + { id: 'plan', name: l10n.t('Plan mode'), slashCommand: 'plan' }, ]; if (bypassEnabled) { - items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') }); + items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions'), slashCommand: 'yolo' }); } return { @@ -180,6 +180,7 @@ export function buildPermissionModeItems(bypassEnabled: boolean): vscode.ChatSes name: l10n.t('Permission Mode'), description: l10n.t('Pick Permission Mode'), items, + kind: 'permissions', }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index e190394cec3e1..4032a1dd2f8e1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -14,7 +14,8 @@ import { NullNativeEnvService } from '../../../../platform/env/common/nullEnvSer import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; import { IGitService, RepoContext } from '../../../../platform/git/common/gitService'; -import { IOctoKitService } from '../../../../platform/github/common/githubService'; +import { IGithubRepositoryService, IOctoKitService } from '../../../../platform/github/common/githubService'; +import { IFetcherService } from '../../../../platform/networking/common/fetcherService'; import { ILogService } from '../../../../platform/log/common/logService'; import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index'; import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger'; @@ -48,6 +49,7 @@ import { IChatDelegationSummaryService } from '../../copilotcli/common/delegatio import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../../copilotcli/node/copilotcliPromptResolver'; import { CopilotCLISession, CopilotCLISessionInput } from '../../copilotcli/node/copilotcliSession'; +import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService'; import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService'; import { ICopilotCLIMCPHandler } from '../../copilotcli/node/mcpHandler'; import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from '../../copilotcli/node/test/testHelpers'; @@ -392,7 +394,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { } }(); } - const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService()); + const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), new MockAuthenticationService(), new class extends mock() { }(), new class extends mock() { }()); cliSessions.push(session); return disposables.add(session); } diff --git a/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts b/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts index 83a3e21d252d1..7c0268d0ba5ef 100644 --- a/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts +++ b/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts @@ -5,6 +5,7 @@ import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; +import { INTEGRATION_ID } from '../../../platform/endpoint/common/licenseAgreement'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import type { CreateSessionFailureReason, CreateSessionResult, CloudSession, SessionEvent } from '../common/cloudSessionTypes'; @@ -150,7 +151,7 @@ export class CloudSessionApiClient { const headers: Record = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${bearerToken}`, - 'Copilot-Integration-Id': 'vscode-chat', + 'Copilot-Integration-Id': INTEGRATION_ID, }; return { url, headers }; diff --git a/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts b/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts index 655bc926f96dd..548774b5c110e 100644 --- a/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts +++ b/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts @@ -5,6 +5,7 @@ import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; +import { INTEGRATION_ID } from '../../../platform/endpoint/common/licenseAgreement'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; /** Cloud query endpoint path. */ @@ -77,7 +78,7 @@ export class CloudSessionStoreClient { method: 'POST', headers: { 'Authorization': `Bearer ${bearerToken}`, - 'Copilot-Integration-Id': 'vscode-chat', + 'Copilot-Integration-Id': INTEGRATION_ID, }, json: { query: sql }, timeout: REQUEST_TIMEOUT_MS, diff --git a/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts b/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts index bbf2ac54b2f88..daec78f30597c 100644 --- a/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts +++ b/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts @@ -125,6 +125,7 @@ export class CodeBlockTrackingChatResponseStream implements ChatResponseStream { workspaceEdit = this.forward(this._wrapped.workspaceEdit?.bind(this._wrapped) || (() => { })); confirmation = this.forward(this._wrapped.confirmation.bind(this._wrapped)); warning = this.forward(this._wrapped.warning.bind(this._wrapped)); + info = this.forward(this._wrapped.info.bind(this._wrapped)); hookProgress = this.forward(this._wrapped.hookProgress.bind(this._wrapped)); reference2 = this.forward(this._wrapped.reference2.bind(this._wrapped)); codeCitation = this.forward(this._wrapped.codeCitation.bind(this._wrapped)); @@ -391,4 +392,4 @@ function mightBeFence(line: string) { } } return true; -} \ No newline at end of file +} diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index bbf7d45a66f26..1bad1a2e2ce46 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -10,6 +10,7 @@ import type * as vscode from 'vscode'; import { IChatSessionService } from '../../../platform/chat/common/chatSessionService'; import { ChatFetchResponseType, ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; +import { getTextPart } from '../../../platform/chat/common/globalStringUtils'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { isAnthropicFamily, isGptFamily, modelCanUseApplyPatchExclusively, modelCanUseReplaceStringExclusively, modelSupportsApplyPatch, modelSupportsMultiReplaceString, modelSupportsReplaceString, modelSupportsSimplifiedApplyPatchInstructions } from '../../../platform/endpoint/common/chatModelCapabilities'; import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; @@ -884,6 +885,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I const numRoundsInCurrentTurn = rounds.length; const lastUsedTool = rounds.at(-1)?.toolCalls?.at(-1)?.name ?? history.at(-1)?.rounds.at(-1)?.toolCalls?.at(-1)?.name ?? 'none'; + const promptTypes = messages.map(msg => `${msg.role}${'name' in msg && msg.name ? `-${msg.name}` : ''}:${getTextPart(msg.content).length}`).join(','); /* __GDPR__ "summarizedConversationHistory" : { "owner": "bhavyau", @@ -895,6 +897,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID." }, "lastUsedTool": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The last tool used before summarization." }, "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request ID from the summarization call." }, + "promptTypes": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Role and character count of each prompt message in order, as a proxy for cache hit rate (e.g. system:1234,user:567)." }, "numRounds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total tool call rounds." }, "turnIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The index of the current turn." }, "curTurnRoundIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The index of the current round within the current turn." }, @@ -913,6 +916,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I chatRequestId: associatedRequestId, lastUsedTool, requestId: response.requestId, + promptTypes, }, { numRounds, turnIndex: history.length, diff --git a/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts b/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts index 5ff466e90a788..5c8b96734c429 100644 --- a/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts +++ b/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts @@ -79,6 +79,11 @@ export class ResponseStreamWithLinkification implements FinalizableChatResponseS return this; } + info(value: string | MarkdownString): ChatResponseStream { + this.enqueue(() => this._progress.info(value), false); + return this; + } + hookProgress(hookType: ChatHookType, stopReason?: string, systemMessage?: string): ChatResponseStream { this.enqueue(() => this._progress.hookProgress(hookType, stopReason, systemMessage), false); return this; diff --git a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx index adf6db40533e3..7e38db57951e0 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx @@ -10,6 +10,7 @@ import { ChatMessage } from '@vscode/prompt-tsx/dist/base/output/rawTypes'; import type { ChatResponsePart, ChatResultPromptTokenDetail, LanguageModelToolInformation, NotebookDocument, Progress } from 'vscode'; import { IChatHookService, PreCompactHookInput } from '../../../../platform/chat/common/chatHookService'; import { ChatFetchResponseType, ChatLocation, ChatResponse, FetchSuccess } from '../../../../platform/chat/common/commonTypes'; +import { getTextPart } from '../../../../platform/chat/common/globalStringUtils'; import { IHistoricalTurn, ISessionTranscriptService } from '../../../../platform/chat/common/sessionTranscriptService'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { isAnthropicFamily, isGeminiFamily } from '../../../../platform/endpoint/common/chatModelCapabilities'; @@ -675,6 +676,7 @@ class ConversationHistorySummarizer { } let summaryResponse: ChatResponse; + let promptTypes: string | undefined; try { const normalizedTools = mode === SummaryMode.Full ? normalizeToolSchema( endpoint.family, @@ -735,6 +737,7 @@ class ConversationHistorySummarizer { } } + promptTypes = messages.map(msg => `${msg.role}${'name' in msg && msg.name ? `-${msg.name}` : ''}:${getTextPart(msg.content).length}`).join(','); summaryResponse = await endpoint.makeChatRequest2({ debugName: `summarizeConversationHistory-${mode}`, messages, @@ -764,7 +767,7 @@ class ConversationHistorySummarizer { const durationMs = stopwatch.elapsed(); return { - result: await this.handleSummarizationResponse(summaryResponse, mode, durationMs), + result: await this.handleSummarizationResponse(summaryResponse, mode, durationMs, promptTypes), promptTokenDetails, model: endpoint.model, summarizationMode: mode, @@ -772,7 +775,7 @@ class ConversationHistorySummarizer { }; } - private async handleSummarizationResponse(response: ChatResponse, mode: SummaryMode, elapsedTime: number): Promise> { + private async handleSummarizationResponse(response: ChatResponse, mode: SummaryMode, elapsedTime: number, promptTypes?: string): Promise> { if (response.type !== ChatFetchResponseType.Success) { const outcome = response.type; this.sendSummarizationTelemetry(outcome, response.requestId, this.props.endpoint.model, mode, elapsedTime, undefined, response.reason); @@ -795,7 +798,7 @@ class ConversationHistorySummarizer { throw new Error('Summary too large'); } - this.sendSummarizationTelemetry('success', response.requestId, this.props.endpoint.model, mode, elapsedTime, response.usage); + this.sendSummarizationTelemetry('success', response.requestId, this.props.endpoint.model, mode, elapsedTime, response.usage, undefined, promptTypes); this.logInfo(`Summarization usage: prompt=${response.usage?.prompt_tokens ?? '?'}, cached=${response.usage?.prompt_tokens_details?.cached_tokens ?? '?'}, completion=${response.usage?.completion_tokens ?? '?'}`, mode); return response; } @@ -809,8 +812,9 @@ class ConversationHistorySummarizer { * @param elapsedTime Total time in milliseconds taken for the summarization request * @param usage Token usage information for the summarization request, if available * @param detailedOutcome Optional detailed reason for non-success outcomes (for example, error or cancellation reason) + * @param promptTypes Optional pre-computed promptTypes string for the summarization request */ - private sendSummarizationTelemetry(outcome: string, requestId: string, model: string, mode: SummaryMode, elapsedTime: number, usage: APIUsage | undefined, detailedOutcome?: string): void { + private sendSummarizationTelemetry(outcome: string, requestId: string, model: string, mode: SummaryMode, elapsedTime: number, usage: APIUsage | undefined, detailedOutcome?: string, promptTypes?: string): void { const { numRounds, numRoundsSinceLastSummarization } = computeSummarizationRoundCounts(this.props.promptContext.history, this.props.promptContext.toolCallRounds); const turnIndex = this.props.promptContext.history.length; @@ -833,6 +837,7 @@ class ConversationHistorySummarizer { "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID used for the summarization." }, "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request ID from the summarization call." }, "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID that this summarization ran during." }, + "promptTypes": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Role and character count of each prompt message in order, as a proxy for cache hit rate (e.g. system:1234,user:567)." }, "numRounds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of tool call rounds before this summarization was triggered." }, "numRoundsSinceLastSummarization": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of tool call rounds since the last summarization." }, "turnIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The index of the current turn." }, @@ -860,6 +865,7 @@ class ConversationHistorySummarizer { conversationId, mode, summarizationMode: mode, // Try to unstick GDPR + promptTypes, }, { numRounds, numRoundsSinceLastSummarization, diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index 4012f33aef7a7..e09a4e37cb607 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -206,9 +206,6 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, } async function isExternalInstructionsFile(normalizedUri: URI, customInstructionsService: ICustomInstructionsService, buildPromptContext?: IBuildPromptContext): Promise { - if (customInstructionsService.getExtensionSkillInfo(normalizedUri)) { - return true; - } if (buildPromptContext) { const instructionIndexFile = getInstructionsIndexFile(buildPromptContext, customInstructionsService); if (instructionIndexFile) { @@ -227,6 +224,9 @@ async function isExternalInstructionsFile(normalizedUri: URI, customInstructions return true; } } else { + if (customInstructionsService.getExtensionSkillInfo(normalizedUri)) { + return true; + } // Note: this fallback check does not handle scenario where model passes file:// for userData schemes. if (await customInstructionsService.isExternalInstructionsFile(normalizedUri)) { return true; diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 9e585d981b1b9..506e7cece1358 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -621,6 +621,7 @@ export namespace ConfigKey { export const CLIAutoCommitEnabled = defineSetting('chat.cli.autoCommit.enabled', ConfigType.Simple, true); export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, false); export const CLIThinkingEffortEnabled = defineSetting('chat.cli.thinkingEffort.enabled', ConfigType.Simple, true); + export const CLIRemoteEnabled = defineSetting('chat.cli.remote.enabled', ConfigType.Simple, false); export const CLISessionControllerForSessionsApp = defineSetting('chat.cli.sessionControllerForSessionsApp.enabled', ConfigType.Simple, false); export const CLITerminalLinks = defineSetting('chat.cli.terminalLinks.enabled', ConfigType.Simple, true); export const RequestLoggerMaxEntries = defineAndMigrateSetting('chat.advanced.debug.requestLogger.maxEntries', 'chat.debug.requestLogger.maxEntries', 100); diff --git a/extensions/copilot/src/platform/endpoint/common/licenseAgreement.ts b/extensions/copilot/src/platform/endpoint/common/licenseAgreement.ts index b12786f57cfcb..63bf9e333dc2e 100644 --- a/extensions/copilot/src/platform/endpoint/common/licenseAgreement.ts +++ b/extensions/copilot/src/platform/endpoint/common/licenseAgreement.ts @@ -9,3 +9,4 @@ * WARNING: Do not move or rename this file. */ export const LICENSE_AGREEMENT: string | undefined = undefined; +export const INTEGRATION_ID: string = 'code-oss'; diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index d2c09cac7ed37..24cc6056d0cf3 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -78,6 +78,11 @@ interface AnthropicStreamEvent { signature?: string; stop_reason?: string; stop_sequence?: string; + stop_details?: { + category?: string; + explanation?: string; + type?: string; + }; }; copilot_annotations?: { IPCodeCitations?: AnthropicIPCodeCitation[]; @@ -603,6 +608,7 @@ export class AnthropicMessagesProcessor { private cacheReadTokens: number = 0; private contextManagementResponse?: ContextManagementResponse; private stopReason: string | undefined; + private stopDetails?: { category?: string; explanation?: string; type?: string }; constructor( private readonly telemetryData: TelemetryData, @@ -710,26 +716,6 @@ export class AnthropicMessagesProcessor { encrypted: data, } }); - } else if (chunk.content_block) { - const unknownType = (chunk.content_block as { type?: string }).type ?? 'undefined'; - this.logService.warn(`[messagesAPI] Unknown content_block type '${unknownType}' at index ${chunk.index ?? -1} for model ${this.model}`); - - /* __GDPR__ - "messagesApi.unknownContentBlock" : { - "owner": "bhavyaus", - "comment": "Tracks unknown Anthropic content block types", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request ID for correlation" }, - "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that emitted the unknown block" }, - "blockType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The unknown content_block.type string" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent('messagesApi.unknownContentBlock', - { - requestId: this.requestId, - model: this.model, - blockType: unknownType, - } - ); } return; case 'content_block_delta': @@ -818,10 +804,13 @@ export class AnthropicMessagesProcessor { contextManagement: chunk.context_management }); } - // Track stop_reason for determining finish reason in message_stop + // Track stop_reason and stop_details for determining finish reason in message_stop if (chunk.delta?.stop_reason) { this.stopReason = chunk.delta.stop_reason; } + if (chunk.delta?.stop_details) { + this.stopDetails = chunk.delta.stop_details; + } return; case 'message_stop': { if (this.contextManagementResponse) { @@ -864,6 +853,28 @@ export class AnthropicMessagesProcessor { } ); } + if (this.stopReason === 'refusal') { + const category = this.stopDetails?.category ?? 'unknown'; + this.logService.warn(`[messagesAPI] Refusal received: category='${category}' for model ${this.model}`); + + /* __GDPR__ + "messagesApi.refusal" : { + "owner": "bhavyaus", + "comment": "Tracks Anthropic refusal responses including cyber and other policy categories", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request ID for correlation" }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that produced the refusal" }, + "category": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The refusal category (e.g. cyber, content_policy)" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent('messagesApi.refusal', + { + requestId: this.requestId, + model: this.model, + category, + } + ); + } + let finishReason: FinishedCompletionReason; switch (this.stopReason) { case 'refusal': diff --git a/extensions/copilot/src/platform/ignore/node/test/mockAuthenticationService.ts b/extensions/copilot/src/platform/ignore/node/test/mockAuthenticationService.ts index 4ac9aa8c309ce..8cb4ef4bb8d0e 100644 --- a/extensions/copilot/src/platform/ignore/node/test/mockAuthenticationService.ts +++ b/extensions/copilot/src/platform/ignore/node/test/mockAuthenticationService.ts @@ -5,14 +5,14 @@ import type { AuthenticationGetSessionOptions, AuthenticationSession } from 'vscode'; import { Event } from '../../../../util/vs/base/common/event'; +import { IAuthenticationService } from '../../../authentication/common/authentication'; import { CopilotToken } from '../../../authentication/common/copilotToken'; /** * A minimal mock implementation of IAuthenticationService for testing. * Returns undefined for all session methods by default. - * Note: Does not fully implement IAuthenticationService - only the methods needed for tests. */ -export class MockAuthenticationService { +export class MockAuthenticationService implements IAuthenticationService { declare readonly _serviceBrand: undefined; readonly isMinimalMode = false; @@ -25,6 +25,8 @@ export class MockAuthenticationService { copilotToken: Omit | undefined = undefined; speculativeDecodingEndpointToken: string | undefined = undefined; + getGitHubSession(_kind: 'permissive' | 'any', _options?: AuthenticationGetSessionOptions): Promise; + getGitHubSession(_kind: 'permissive' | 'any', _options?: AuthenticationGetSessionOptions): Promise; getGitHubSession(_kind: 'permissive' | 'any', _options?: AuthenticationGetSessionOptions): Promise { return Promise.resolve(undefined); } diff --git a/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts b/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts index 4521d09a15c5e..13b03bf86e6c5 100644 --- a/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts +++ b/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts @@ -6,6 +6,7 @@ import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { Config, ConfigKey, ExperimentBasedConfig, ExperimentBasedConfigType, IConfigurationService } from '../../configuration/common/configurationService'; +import { INTEGRATION_ID } from '../../endpoint/common/licenseAgreement'; import { IEnvService } from '../../env/common/envService'; import { ILogService } from '../../log/common/logService'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; @@ -131,7 +132,7 @@ export class FetcherService extends Disposable implements IFetcherService { createWebSocket(url: string, options?: WebSocketConnectOptions): WebSocketConnection { if (options?.headers) { delete options.headers['Request-Hmac']; - options.headers['Copilot-Integration-Id'] = 'vscode-chat'; + options.headers['Copilot-Integration-Id'] = INTEGRATION_ID; } return createWebSocket(url, options); } diff --git a/extensions/copilot/src/util/common/chatResponseStreamImpl.ts b/extensions/copilot/src/util/common/chatResponseStreamImpl.ts index 7eb94126a3898..07db8ae9aa74d 100644 --- a/extensions/copilot/src/util/common/chatResponseStreamImpl.ts +++ b/extensions/copilot/src/util/common/chatResponseStreamImpl.ts @@ -5,7 +5,7 @@ import { ChatResponseReferencePartStatusKind } from '@vscode/prompt-tsx'; import type { ChatQuestion, ChatResponseFileTree, ChatResponseStream, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, ExtendedChatResponsePart, Location, NotebookEdit, Progress, ThinkingDelta, Uri } from 'vscode'; -import { ChatHookType, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, MarkdownString, TextEdit } from '../../vscodeTypes'; +import { ChatHookType, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, MarkdownString, TextEdit } from '../../vscodeTypes'; import type { ThemeIcon } from '../vs/base/common/themables'; @@ -215,6 +215,10 @@ export class ChatResponseStreamImpl implements FinalizableChatResponseStream { this._push(new ChatResponseWarningPart(value)); } + info(value: string | MarkdownString): void { + this._push(new ChatResponseInfoPart(value)); + } + beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData): void { if (this._beginToolInvocation) { this._beginToolInvocation(toolCallId, toolName, streamData); diff --git a/extensions/copilot/src/util/common/test/shims/chatTypes.ts b/extensions/copilot/src/util/common/test/shims/chatTypes.ts index c80870a8c1c7d..aca555f17365c 100644 --- a/extensions/copilot/src/util/common/test/shims/chatTypes.ts +++ b/extensions/copilot/src/util/common/test/shims/chatTypes.ts @@ -110,6 +110,13 @@ export class ChatResponseWarningPart { } } +export class ChatResponseInfoPart { + value: vscode.MarkdownString; + constructor(value: string | vscode.MarkdownString) { + this.value = typeof value === 'string' ? new MarkdownString(value) : value; + } +} + export class ChatResponseReferencePart { value: vscode.Uri | vscode.Location; constructor(value: vscode.Uri | vscode.Location) { diff --git a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts index eea8bea42d64a..0cae1fd5461c1 100644 --- a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts +++ b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts @@ -18,7 +18,7 @@ import { SnippetString } from '../../../vs/workbench/api/common/extHostTypes/sni import { SnippetTextEdit } from '../../../vs/workbench/api/common/extHostTypes/snippetTextEdit'; import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/extHostTypes/symbolInformation'; import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit'; -import { AISearchKeyword, ChatErrorLevel, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; +import { AISearchKeyword, ChatErrorLevel, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing'; import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; import { t } from './l10n'; @@ -58,6 +58,7 @@ const shim: typeof vscodeTypes = { ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseWarningPart, + ChatResponseInfoPart, ChatResponseHookPart, ChatResponseReferencePart, ChatResponseReferencePart2, diff --git a/extensions/copilot/src/vscodeTypes.ts b/extensions/copilot/src/vscodeTypes.ts index 254d39c340a79..f3a6890f0936b 100644 --- a/extensions/copilot/src/vscodeTypes.ts +++ b/extensions/copilot/src/vscodeTypes.ts @@ -37,6 +37,7 @@ export import ChatResponseReferencePart2 = vscode.ChatResponseReferencePart2; export import ChatResponseCodeCitationPart = vscode.ChatResponseCodeCitationPart; export import ChatResponseCommandButtonPart = vscode.ChatResponseCommandButtonPart; export import ChatResponseWarningPart = vscode.ChatResponseWarningPart; +export import ChatResponseInfoPart = vscode.ChatResponseInfoPart; export import ChatResponseMovePart = vscode.ChatResponseMovePart; export import ChatResponseExtensionsPart = vscode.ChatResponseExtensionsPart; export import ChatResponseExternalEditPart = vscode.ChatResponseExternalEditPart; diff --git a/extensions/css-language-features/.vscodeignore b/extensions/css-language-features/.vscodeignore index c3f4d9fe3dcf4..125bf9e5be6f9 100644 --- a/extensions/css-language-features/.vscodeignore +++ b/extensions/css-language-features/.vscodeignore @@ -7,6 +7,7 @@ server/src/** client/out/** server/out/** **/tsconfig*.json +**/*.tsbuildinfo server/test/** server/bin/** server/build/** diff --git a/extensions/debug-auto-launch/.vscodeignore b/extensions/debug-auto-launch/.vscodeignore index 0628555db0021..2b483a5826130 100644 --- a/extensions/debug-auto-launch/.vscodeignore +++ b/extensions/debug-auto-launch/.vscodeignore @@ -1,5 +1,6 @@ src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild*.mts package-lock.json diff --git a/extensions/debug-server-ready/.vscodeignore b/extensions/debug-server-ready/.vscodeignore index 7b79c7b47b449..f57978624f3b9 100644 --- a/extensions/debug-server-ready/.vscodeignore +++ b/extensions/debug-server-ready/.vscodeignore @@ -1,5 +1,6 @@ src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild*.mts package-lock.json diff --git a/extensions/emmet/.vscodeignore b/extensions/emmet/.vscodeignore index c63ff6f6805d2..8df8d7ba24422 100644 --- a/extensions/emmet/.vscodeignore +++ b/extensions/emmet/.vscodeignore @@ -3,6 +3,7 @@ test-workspace/** src/** out/** tsconfig*.json +**/*.tsbuildinfo esbuild* CONTRIBUTING.md cgmanifest.json diff --git a/extensions/extension-editing/.vscodeignore b/extensions/extension-editing/.vscodeignore index 82f90155181a7..97f9cb2b85f3e 100644 --- a/extensions/extension-editing/.vscodeignore +++ b/extensions/extension-editing/.vscodeignore @@ -1,6 +1,7 @@ test/** src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild*.mts package-lock.json diff --git a/extensions/git-base/.vscodeignore b/extensions/git-base/.vscodeignore index 44bb9f7ee78e3..7b7cd621ea98c 100644 --- a/extensions/git-base/.vscodeignore +++ b/extensions/git-base/.vscodeignore @@ -3,4 +3,5 @@ build/** cgmanifest.json esbuild*.mts tsconfig*.json +**/*.tsbuildinfo diff --git a/extensions/git/.vscodeignore b/extensions/git/.vscodeignore index 9de840770944a..310319bf0de20 100644 --- a/extensions/git/.vscodeignore +++ b/extensions/git/.vscodeignore @@ -2,6 +2,7 @@ src/** test/** out/** tsconfig*.json +**/*.tsbuildinfo build/** esbuild*.mts package-lock.json diff --git a/extensions/github-authentication/.vscodeignore b/extensions/github-authentication/.vscodeignore index fd8583ab8d125..de6c3dc66a951 100644 --- a/extensions/github-authentication/.vscodeignore +++ b/extensions/github-authentication/.vscodeignore @@ -6,4 +6,5 @@ build/** esbuild.mts esbuild.browser.mts tsconfig*.json +**/*.tsbuildinfo package-lock.json diff --git a/extensions/github/.vscodeignore b/extensions/github/.vscodeignore index a6590bd39343c..8b706f52e56f8 100644 --- a/extensions/github/.vscodeignore +++ b/extensions/github/.vscodeignore @@ -4,5 +4,6 @@ out/** build/** esbuild*.mts tsconfig*.json +**/*.tsbuildinfo package-lock.json testWorkspace/** diff --git a/extensions/grunt/.vscodeignore b/extensions/grunt/.vscodeignore index e6cd7f0ed82e6..7f181a42d3a1c 100644 --- a/extensions/grunt/.vscodeignore +++ b/extensions/grunt/.vscodeignore @@ -1,6 +1,7 @@ test/** src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild* package-lock.json diff --git a/extensions/gulp/.vscodeignore b/extensions/gulp/.vscodeignore index 58e98fc51822d..dc357d4e806f5 100644 --- a/extensions/gulp/.vscodeignore +++ b/extensions/gulp/.vscodeignore @@ -1,5 +1,6 @@ src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild* package-lock.json diff --git a/extensions/html-language-features/.vscodeignore b/extensions/html-language-features/.vscodeignore index 90ce71c9388ba..0fa7462e97526 100644 --- a/extensions/html-language-features/.vscodeignore +++ b/extensions/html-language-features/.vscodeignore @@ -8,6 +8,7 @@ server/src/** client/out/** server/out/** **/tsconfig*.json +**/*.tsbuildinfo server/test/** server/bin/** server/build/** diff --git a/extensions/ipynb/.vscodeignore b/extensions/ipynb/.vscodeignore index 6823ef2cb7ab9..2f1a873c9a914 100644 --- a/extensions/ipynb/.vscodeignore +++ b/extensions/ipynb/.vscodeignore @@ -3,6 +3,7 @@ src/** notebook-src/** out/** tsconfig*.json +**/*.tsbuildinfo esbuild*.mts package-lock.json .gitignore diff --git a/extensions/jake/.vscodeignore b/extensions/jake/.vscodeignore index 58e98fc51822d..dc357d4e806f5 100644 --- a/extensions/jake/.vscodeignore +++ b/extensions/jake/.vscodeignore @@ -1,5 +1,6 @@ src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild* package-lock.json diff --git a/extensions/javascript/.vscodeignore b/extensions/javascript/.vscodeignore index 488fc2248af70..48e253ac26987 100644 --- a/extensions/javascript/.vscodeignore +++ b/extensions/javascript/.vscodeignore @@ -2,4 +2,5 @@ test/** src/**/*.ts syntaxes/Readme.md tsconfig*.json +**/*.tsbuildinfo cgmanifest.json diff --git a/extensions/json-language-features/.vscodeignore b/extensions/json-language-features/.vscodeignore index 21b767e37ec79..ba9d21edc7511 100644 --- a/extensions/json-language-features/.vscodeignore +++ b/extensions/json-language-features/.vscodeignore @@ -6,6 +6,7 @@ server/src/** client/out/** server/out/** **/tsconfig*.json +**/*.tsbuildinfo server/test/** server/bin/** server/build/** diff --git a/extensions/markdown-basics/.vscodeignore b/extensions/markdown-basics/.vscodeignore index 96ebcbb17323f..ecd53599de671 100644 --- a/extensions/markdown-basics/.vscodeignore +++ b/extensions/markdown-basics/.vscodeignore @@ -1,4 +1,5 @@ test/** src/** tsconfig*.json +**/*.tsbuildinfo cgmanifest.json diff --git a/extensions/markdown-language-features/.vscodeignore b/extensions/markdown-language-features/.vscodeignore index 315b1d7877049..31ae4c0eb0bb9 100644 --- a/extensions/markdown-language-features/.vscodeignore +++ b/extensions/markdown-language-features/.vscodeignore @@ -3,6 +3,7 @@ test-workspace/** src/** notebook/** tsconfig*.json +**/*.tsbuildinfo esbuild* out/test/** out/** diff --git a/extensions/markdown-math/.vscodeignore b/extensions/markdown-math/.vscodeignore index 900988455028e..41a570db289a3 100644 --- a/extensions/markdown-math/.vscodeignore +++ b/extensions/markdown-math/.vscodeignore @@ -1,6 +1,7 @@ src/** notebook/** tsconfig*.json +**/*.tsbuildinfo esbuild* cgmanifest.json package-lock.json diff --git a/extensions/media-preview/.vscodeignore b/extensions/media-preview/.vscodeignore index ca6d6ff79d724..20f2061ba44a7 100644 --- a/extensions/media-preview/.vscodeignore +++ b/extensions/media-preview/.vscodeignore @@ -1,6 +1,7 @@ test/** src/** tsconfig*.json +**/*.tsbuildinfo esbuild* out/test/** out/** diff --git a/extensions/merge-conflict/.vscodeignore b/extensions/merge-conflict/.vscodeignore index 0628555db0021..2b483a5826130 100644 --- a/extensions/merge-conflict/.vscodeignore +++ b/extensions/merge-conflict/.vscodeignore @@ -1,5 +1,6 @@ src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild*.mts package-lock.json diff --git a/extensions/mermaid-chat-features/.vscodeignore b/extensions/mermaid-chat-features/.vscodeignore index 485bbd8df38c0..3d32de261ccaa 100644 --- a/extensions/mermaid-chat-features/.vscodeignore +++ b/extensions/mermaid-chat-features/.vscodeignore @@ -3,4 +3,5 @@ esbuild.* cgmanifest.json package-lock.json tsconfig*.json +**/*.tsbuildinfo .gitignore diff --git a/extensions/microsoft-authentication/.vscodeignore b/extensions/microsoft-authentication/.vscodeignore index e61623be63b1f..be0b1cad928cf 100644 --- a/extensions/microsoft-authentication/.vscodeignore +++ b/extensions/microsoft-authentication/.vscodeignore @@ -8,6 +8,7 @@ src/** .gitignore vsc-extension-quickstart.md **/tsconfig*.json +**/*.tsbuildinfo **/tslint.json **/*.map **/*.ts diff --git a/extensions/notebook-renderers/.vscodeignore b/extensions/notebook-renderers/.vscodeignore index 3f07e7128a912..397df44d5c9a0 100644 --- a/extensions/notebook-renderers/.vscodeignore +++ b/extensions/notebook-renderers/.vscodeignore @@ -1,6 +1,7 @@ src/** notebook/** tsconfig*.json +**/*.tsbuildinfo .gitignore esbuild.* src/** diff --git a/extensions/npm/.vscodeignore b/extensions/npm/.vscodeignore index 7e9dd51ede2aa..88b9cc17b7cdb 100644 --- a/extensions/npm/.vscodeignore +++ b/extensions/npm/.vscodeignore @@ -1,6 +1,7 @@ src/** out/** tsconfig*.json +**/*.tsbuildinfo .vscode/** esbuild* package-lock.json diff --git a/extensions/php-language-features/.vscodeignore b/extensions/php-language-features/.vscodeignore index 4a97d0f9e7c4b..8ed564e9aab44 100644 --- a/extensions/php-language-features/.vscodeignore +++ b/extensions/php-language-features/.vscodeignore @@ -2,5 +2,6 @@ src/** out/** tsconfig*.json +**/*.tsbuildinfo esbuild*.mts package-lock.json diff --git a/extensions/php/.vscodeignore b/extensions/php/.vscodeignore index b69551f2053c5..4960e7365dab0 100644 --- a/extensions/php/.vscodeignore +++ b/extensions/php/.vscodeignore @@ -3,5 +3,6 @@ build/** out/test/** src/** tsconfig*.json +**/*.tsbuildinfo cgmanifest.json .vscode diff --git a/extensions/prompt-basics/.vscodeignore b/extensions/prompt-basics/.vscodeignore index 96ebcbb17323f..ecd53599de671 100644 --- a/extensions/prompt-basics/.vscodeignore +++ b/extensions/prompt-basics/.vscodeignore @@ -1,4 +1,5 @@ test/** src/** tsconfig*.json +**/*.tsbuildinfo cgmanifest.json diff --git a/extensions/references-view/.vscodeignore b/extensions/references-view/.vscodeignore index 4a97d0f9e7c4b..8ed564e9aab44 100644 --- a/extensions/references-view/.vscodeignore +++ b/extensions/references-view/.vscodeignore @@ -2,5 +2,6 @@ src/** out/** tsconfig*.json +**/*.tsbuildinfo esbuild*.mts package-lock.json diff --git a/extensions/search-result/.vscodeignore b/extensions/search-result/.vscodeignore index 50bbe59eb5209..e0da2dd252dbe 100644 --- a/extensions/search-result/.vscodeignore +++ b/extensions/search-result/.vscodeignore @@ -1,6 +1,7 @@ src/** out/** tsconfig*.json +**/*.tsbuildinfo esbuild*.mts package-lock.json syntaxes/generateTMLanguage.js diff --git a/extensions/simple-browser/.vscodeignore b/extensions/simple-browser/.vscodeignore index e2740fcfba08f..cce548d8cebdc 100644 --- a/extensions/simple-browser/.vscodeignore +++ b/extensions/simple-browser/.vscodeignore @@ -2,6 +2,7 @@ test/** test-workspace/** src/** tsconfig*.json +**/*.tsbuildinfo out/test/** out/** esbuild*.mts diff --git a/extensions/terminal-suggest/.vscodeignore b/extensions/terminal-suggest/.vscodeignore index 3965a7d70ba5e..73da070d75428 100644 --- a/extensions/terminal-suggest/.vscodeignore +++ b/extensions/terminal-suggest/.vscodeignore @@ -1,6 +1,7 @@ src/** out/** tsconfig*.json +**/*.tsbuildinfo .vscode/** esbuild*.mts package-lock.json diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 0561134ddad08..180c0a5a7f1b4 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -258,9 +258,9 @@ "gauge.errorBackground": "#F287724D", "chat.requestBubbleBackground": "#ffffff13", "chat.requestBubbleHoverBackground": "#ffffff22", - "chat.inputWorkingBorderColor1": "#9560D8", - "chat.inputWorkingBorderColor2": "#5BC25B", - "chat.inputWorkingBorderColor3": "#4A8FF5", + "chat.inputWorkingBorderColor1": "#E8E8EC", + "chat.inputWorkingBorderColor2": "#8A8A92", + "chat.inputWorkingBorderColor3": "#3A3A40", "editorCommentsWidget.rangeBackground": "#488FAE26", "editorCommentsWidget.rangeActiveBackground": "#488FAE46", "charts.foreground": "#CCCCCC", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 1b24ca3a52ded..8d7dcf21abbb5 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -261,9 +261,9 @@ "chat.requestBubbleBackground": "#EEF4FB", "chat.requestBubbleHoverBackground": "#E6EDFA", "chat.thinkingShimmer": "#999999", - "chat.inputWorkingBorderColor1": "#9B30FF", - "chat.inputWorkingBorderColor2": "#00C853", - "chat.inputWorkingBorderColor3": "#0044FF", + "chat.inputWorkingBorderColor1": "#B8B8C0", + "chat.inputWorkingBorderColor2": "#7A7A82", + "chat.inputWorkingBorderColor3": "#2E2E34", "editorCommentsWidget.rangeBackground": "#EEF4FB", "editorCommentsWidget.rangeActiveBackground": "#E6EDFA", "charts.foreground": "#202020", diff --git a/extensions/tunnel-forwarding/.vscodeignore b/extensions/tunnel-forwarding/.vscodeignore index 0628555db0021..2b483a5826130 100644 --- a/extensions/tunnel-forwarding/.vscodeignore +++ b/extensions/tunnel-forwarding/.vscodeignore @@ -1,5 +1,6 @@ src/** tsconfig*.json +**/*.tsbuildinfo out/** esbuild*.mts package-lock.json diff --git a/extensions/typescript-basics/.vscodeignore b/extensions/typescript-basics/.vscodeignore index e850ef593ee23..62ca93b6de3a0 100644 --- a/extensions/typescript-basics/.vscodeignore +++ b/extensions/typescript-basics/.vscodeignore @@ -2,5 +2,6 @@ build/** src/** test/** tsconfig*.json +**/*.tsbuildinfo cgmanifest.json syntaxes/Readme.md diff --git a/extensions/vscode-api-tests/.vscodeignore b/extensions/vscode-api-tests/.vscodeignore index 43e7067afbd26..0a7a90dd91a74 100644 --- a/extensions/vscode-api-tests/.vscodeignore +++ b/extensions/vscode-api-tests/.vscodeignore @@ -4,3 +4,4 @@ typings/** **/*.map .gitignore tsconfig*.json +**/*.tsbuildinfo diff --git a/extensions/vscode-test-resolver/.vscodeignore b/extensions/vscode-test-resolver/.vscodeignore index ec136872c1762..3becf8ed1b7e2 100644 --- a/extensions/vscode-test-resolver/.vscodeignore +++ b/extensions/vscode-test-resolver/.vscodeignore @@ -4,4 +4,5 @@ typings/** **/*.map .gitignore tsconfig*.json +**/*.tsbuildinfo esbuild*.mts diff --git a/package-lock.json b/package-lock.json index 5ddc165b06578..ef94cb0371075 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.24", + "@github/copilot": "^1.0.28", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -35,7 +35,7 @@ "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-mutex": "^0.5.0", - "@vscode/windows-process-tree": "^0.6.0", + "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.197", "@xterm/addon-image": "^0.10.0-beta.197", @@ -1072,26 +1072,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.24.tgz", - "integrity": "sha512-/nZ2GwhaGq0HeI3W+6LE0JGw25/bipC6tYVa+oQ5tIvAafBazuNt10CXkeaor+u9oBWLZtPbdTyAzE2tjy9NpQ==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.28.tgz", + "integrity": "sha512-S1Y+KnhywjIsK1DzskoCqPVC3uURohvCRyDkGPWXvMw+lXO5ryOJvHFZDDw7MSRjT7ea7T0m8e3yKdK0OxJhnw==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.24", - "@github/copilot-darwin-x64": "1.0.24", - "@github/copilot-linux-arm64": "1.0.24", - "@github/copilot-linux-x64": "1.0.24", - "@github/copilot-win32-arm64": "1.0.24", - "@github/copilot-win32-x64": "1.0.24" + "@github/copilot-darwin-arm64": "1.0.28", + "@github/copilot-darwin-x64": "1.0.28", + "@github/copilot-linux-arm64": "1.0.28", + "@github/copilot-linux-x64": "1.0.28", + "@github/copilot-win32-arm64": "1.0.28", + "@github/copilot-win32-x64": "1.0.28" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.24.tgz", - "integrity": "sha512-lejn6KV+09rZEICX3nRx9a58DQFQ2kK3NJ3EICfVLngUCWIUmwn1BLezjeTQc9YNasHltA1hulvfsWqX+VjlMw==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.28.tgz", + "integrity": "sha512-Bkis5dkOsdgaK95j/8mgIGSxHlRuL211Wa3S4MeeYGrilZweaG20sa0jktzagL6XFxfPRKBC87E+fDFyXz1L3g==", "cpu": [ "arm64" ], @@ -1105,9 +1105,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.24.tgz", - "integrity": "sha512-r2F3keTvr4Bunz3V+waRAvsHgqsVQGyIZFBebsNPWxBX1eh3IXgtBqxCR7vXTFyZonQ8VaiJH3YYEfAhyKsk9g==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.28.tgz", + "integrity": "sha512-0RIabmr05KgPPUcD4kpKNBGg/eRwJF2NrYtibDUCIRFWKZu7q0m9c9EURpW0wOO32cXZtAQ+BmJIGlqfCkt6gA==", "cpu": [ "x64" ], @@ -1121,9 +1121,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.24.tgz", - "integrity": "sha512-B3oANXKKKLhnKYozXa/W+DxfCQAHJCs0QKR5rBwNrwJbf656twNgALSxWTSJk+1rEP6MrHCswUAcwjwZL7Q+FQ==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.28.tgz", + "integrity": "sha512-A/zQ4ifN+FSSEHdPHajv5UwygS5BOQ8l1AJMYdVBnnuqVX9bCcRAJJ4S/F60AnaDimzDvVuYSe3lYXRYxz3M5A==", "cpu": [ "arm64" ], @@ -1137,9 +1137,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.24.tgz", - "integrity": "sha512-NGTldizY54B+4Sfhu/GWoEQNMwqqUNgMwbSgBshFv+Hqy1EwuvNWKVov1Y0Vzhp4qAHc6ZxBk/OPIW8Ato9FUg==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.28.tgz", + "integrity": "sha512-0VqoW9hj7qKj+eH2un9E7zn9AbassTZHkKQPsd8yPvLsmPaNJgsHMYDrCCNZNol2ZSGt/XskTfmWQaQM6BoBfg==", "cpu": [ "x64" ], @@ -1176,9 +1176,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.24.tgz", - "integrity": "sha512-/pd/kgef7/HIIg1SQq4q8fext39pDSC44jHB10KkhfgG1WaDFhQbc/aSSMQfxeldkRbQh6VANp8WtGQdwtMCBA==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.28.tgz", + "integrity": "sha512-f28NKudBtIXTpIliHGJbRhEfCItsXKWNzXzgqgmP8FZB+JYrqG/ysU2qCUCxhpv3PLjMLWqnsWs+mIvVLTH9zw==", "cpu": [ "arm64" ], @@ -1192,9 +1192,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.24.tgz", - "integrity": "sha512-RDvOiSvyEJwELqErwANJTrdRuMIHkwPE4QK7Le7WsmaSKxiuS4H1Pa8Yxnd2FWrMsCHEbase23GJlymbnGYLXQ==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.28.tgz", + "integrity": "sha512-b9ZEx2i5P7DZTP66FXTfwf81r5kbAqs2GEJjDdevCwxH7cRexqM9eBxQGj1zGtm4qXF7JGK2eH6Ay7NC28m1Iw==", "cpu": [ "x64" ], @@ -4132,9 +4132,9 @@ } }, "node_modules/@vscode/windows-process-tree": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.6.3.tgz", - "integrity": "sha512-mjirLbtgjv7P6fwD8gx7iaY961EfGqUExGvfzsKl3spLfScg57ejlMi+7O1jfJqpM2Zly9DTSxyY4cFsDN6c9Q==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.7.0.tgz", + "integrity": "sha512-uH58Fofu1bgiulsY2svyGOLLKAYNJ0Req4PioPd7BZzHRziuiBRw1SxyT7wvsYHvm7eKWwzwo6mZpExDfwX9iA==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index f9665ffa74777..76c12a4fafc03 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.118.0", - "distro": "d9cb505fab31e4606739394220a6892cac0e7046", + "distro": "0cd3df038e4308fc174836ca4eb8faa37e00d5cf", "author": { "name": "Microsoft Corporation" }, @@ -90,7 +90,7 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.24", + "@github/copilot": "^1.0.28", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -114,7 +114,7 @@ "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-mutex": "^0.5.0", - "@vscode/windows-process-tree": "^0.6.0", + "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.197", "@xterm/addon-image": "^0.10.0-beta.197", diff --git a/remote/package-lock.json b/remote/package-lock.json index d09143e7a6eca..24565628e403c 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.24", + "@github/copilot": "^1.0.28", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -23,7 +23,7 @@ "@vscode/sqlite3": "5.1.12-vscode", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@vscode/windows-process-tree": "^0.6.0", + "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.197", "@xterm/addon-image": "^0.10.0-beta.197", @@ -83,26 +83,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.24.tgz", - "integrity": "sha512-/nZ2GwhaGq0HeI3W+6LE0JGw25/bipC6tYVa+oQ5tIvAafBazuNt10CXkeaor+u9oBWLZtPbdTyAzE2tjy9NpQ==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.28.tgz", + "integrity": "sha512-S1Y+KnhywjIsK1DzskoCqPVC3uURohvCRyDkGPWXvMw+lXO5ryOJvHFZDDw7MSRjT7ea7T0m8e3yKdK0OxJhnw==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.24", - "@github/copilot-darwin-x64": "1.0.24", - "@github/copilot-linux-arm64": "1.0.24", - "@github/copilot-linux-x64": "1.0.24", - "@github/copilot-win32-arm64": "1.0.24", - "@github/copilot-win32-x64": "1.0.24" + "@github/copilot-darwin-arm64": "1.0.28", + "@github/copilot-darwin-x64": "1.0.28", + "@github/copilot-linux-arm64": "1.0.28", + "@github/copilot-linux-x64": "1.0.28", + "@github/copilot-win32-arm64": "1.0.28", + "@github/copilot-win32-x64": "1.0.28" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.24.tgz", - "integrity": "sha512-lejn6KV+09rZEICX3nRx9a58DQFQ2kK3NJ3EICfVLngUCWIUmwn1BLezjeTQc9YNasHltA1hulvfsWqX+VjlMw==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.28.tgz", + "integrity": "sha512-Bkis5dkOsdgaK95j/8mgIGSxHlRuL211Wa3S4MeeYGrilZweaG20sa0jktzagL6XFxfPRKBC87E+fDFyXz1L3g==", "cpu": [ "arm64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.24.tgz", - "integrity": "sha512-r2F3keTvr4Bunz3V+waRAvsHgqsVQGyIZFBebsNPWxBX1eh3IXgtBqxCR7vXTFyZonQ8VaiJH3YYEfAhyKsk9g==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.28.tgz", + "integrity": "sha512-0RIabmr05KgPPUcD4kpKNBGg/eRwJF2NrYtibDUCIRFWKZu7q0m9c9EURpW0wOO32cXZtAQ+BmJIGlqfCkt6gA==", "cpu": [ "x64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.24.tgz", - "integrity": "sha512-B3oANXKKKLhnKYozXa/W+DxfCQAHJCs0QKR5rBwNrwJbf656twNgALSxWTSJk+1rEP6MrHCswUAcwjwZL7Q+FQ==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.28.tgz", + "integrity": "sha512-A/zQ4ifN+FSSEHdPHajv5UwygS5BOQ8l1AJMYdVBnnuqVX9bCcRAJJ4S/F60AnaDimzDvVuYSe3lYXRYxz3M5A==", "cpu": [ "arm64" ], @@ -148,9 +148,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.24.tgz", - "integrity": "sha512-NGTldizY54B+4Sfhu/GWoEQNMwqqUNgMwbSgBshFv+Hqy1EwuvNWKVov1Y0Vzhp4qAHc6ZxBk/OPIW8Ato9FUg==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.28.tgz", + "integrity": "sha512-0VqoW9hj7qKj+eH2un9E7zn9AbassTZHkKQPsd8yPvLsmPaNJgsHMYDrCCNZNol2ZSGt/XskTfmWQaQM6BoBfg==", "cpu": [ "x64" ], @@ -187,9 +187,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.24.tgz", - "integrity": "sha512-/pd/kgef7/HIIg1SQq4q8fext39pDSC44jHB10KkhfgG1WaDFhQbc/aSSMQfxeldkRbQh6VANp8WtGQdwtMCBA==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.28.tgz", + "integrity": "sha512-f28NKudBtIXTpIliHGJbRhEfCItsXKWNzXzgqgmP8FZB+JYrqG/ysU2qCUCxhpv3PLjMLWqnsWs+mIvVLTH9zw==", "cpu": [ "arm64" ], @@ -203,9 +203,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.24.tgz", - "integrity": "sha512-RDvOiSvyEJwELqErwANJTrdRuMIHkwPE4QK7Le7WsmaSKxiuS4H1Pa8Yxnd2FWrMsCHEbase23GJlymbnGYLXQ==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.28.tgz", + "integrity": "sha512-b9ZEx2i5P7DZTP66FXTfwf81r5kbAqs2GEJjDdevCwxH7cRexqM9eBxQGj1zGtm4qXF7JGK2eH6Ay7NC28m1Iw==", "cpu": [ "x64" ], @@ -735,9 +735,9 @@ } }, "node_modules/@vscode/windows-process-tree": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.6.3.tgz", - "integrity": "sha512-mjirLbtgjv7P6fwD8gx7iaY961EfGqUExGvfzsKl3spLfScg57ejlMi+7O1jfJqpM2Zly9DTSxyY4cFsDN6c9Q==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.7.0.tgz", + "integrity": "sha512-uH58Fofu1bgiulsY2svyGOLLKAYNJ0Req4PioPd7BZzHRziuiBRw1SxyT7wvsYHvm7eKWwzwo6mZpExDfwX9iA==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/remote/package.json b/remote/package.json index 5b9d937b1680f..c375063b2b397 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.24", + "@github/copilot": "^1.0.28", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -18,7 +18,7 @@ "@vscode/sqlite3": "5.1.12-vscode", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@vscode/windows-process-tree": "^0.6.0", + "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.197", "@xterm/addon-image": "^0.10.0-beta.197", diff --git a/scripts/chat-simulation/common/mock-llm-server.js b/scripts/chat-simulation/common/mock-llm-server.js index ff165dc7168e2..4c072a48ab6ac 100644 --- a/scripts/chat-simulation/common/mock-llm-server.js +++ b/scripts/chat-simulation/common/mock-llm-server.js @@ -444,9 +444,12 @@ function handleRequest(req, res) { family: 'gpt-4o', tokenizer: 'o200k_base', limits: { - max_prompt_tokens: 128000, + // Use a very large token limit so the Responses API compaction + // threshold (90% of max_prompt_tokens) is never reached during + // perf benchmarks. + max_prompt_tokens: 10000000, max_output_tokens: 131072, - max_context_window_tokens: 128000, + max_context_window_tokens: 10000000, }, supports: { streaming: true, @@ -471,9 +474,9 @@ function handleRequest(req, res) { family: 'gpt-4o-mini', tokenizer: 'o200k_base', limits: { - max_prompt_tokens: 128000, + max_prompt_tokens: 10000000, max_output_tokens: 131072, - max_context_window_tokens: 128000, + max_context_window_tokens: 10000000, }, supports: { streaming: true, @@ -508,7 +511,7 @@ function handleRequest(req, res) { type: 'chat', family: 'gpt-4o', tokenizer: 'o200k_base', - limits: { max_prompt_tokens: 128000, max_output_tokens: 131072, max_context_window_tokens: 128000 }, + limits: { max_prompt_tokens: 10000000, max_output_tokens: 131072, max_context_window_tokens: 10000000 }, supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false }, }, }); diff --git a/scripts/chat-simulation/common/utils.js b/scripts/chat-simulation/common/utils.js index 6d8adfd76e128..79c608cf2ec01 100644 --- a/scripts/chat-simulation/common/utils.js +++ b/scripts/chat-simulation/common/utils.js @@ -222,7 +222,7 @@ function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostIns // only processes switches that precede the first non-switch argument. const chromiumFlags = []; if (traceFile) { - chromiumFlags.push(`--enable-tracing=v8.gc,devtools.timeline,blink.user_timing`); + chromiumFlags.push(`--enable-tracing=v8.gc,disabled-by-default-v8.gc,disabled-by-default-v8.gc_stats,devtools.timeline,blink.user_timing`); chromiumFlags.push(`--trace-startup-file=${traceFile}`); chromiumFlags.push(`--enable-tracing-format=json`); } diff --git a/scripts/chat-simulation/merge-ci-summary.js b/scripts/chat-simulation/merge-ci-summary.js index 10d9a4b60ae7d..4c107f9f5dc6b 100644 --- a/scripts/chat-simulation/merge-ci-summary.js +++ b/scripts/chat-simulation/merge-ci-summary.js @@ -49,7 +49,7 @@ function parseArgs() { 'Merge per-group perf results into a single CI summary.', '', 'Options:', - ' --results-dir Directory containing perf-results-* subdirs', + ' --results-dir Directory containing perf-results-* or perf-summary-* subdirs', ' --output Output path for ci-summary.md', ' --leak-summary Path to ci-summary-leak.md (optional)', ' --threshold Regression threshold fraction (default: 0.2)', @@ -72,14 +72,23 @@ function parseArgs() { * @param {string} resultsDir */ function mergeResults(resultsDir) { - const groupDirs = fs.readdirSync(resultsDir) - .filter(d => d.startsWith('perf-results-')) + let groupDirs = fs.readdirSync(resultsDir) + .filter(d => d.startsWith('perf-results-') || d.startsWith('perf-summary-')) .map(d => path.join(resultsDir, d)) .filter(d => fs.statSync(d).isDirectory()); + // Fallback: when download-artifact extracts a single artifact directly into + // resultsDir (no artifact-named subdirectory), treat resultsDir itself as the + // sole group directory if it contains a .chat-simulation-data folder. if (groupDirs.length === 0) { - console.error(`No perf-results-* directories found in ${resultsDir}`); - return null; + const simDataDir = path.join(resultsDir, '.chat-simulation-data'); + if (fs.existsSync(simDataDir) && fs.statSync(simDataDir).isDirectory()) { + console.log(`No named subdirectories found; using ${resultsDir} directly as single group`); + groupDirs = [resultsDir]; + } else { + console.error(`No perf-results-* or perf-summary-* directories found in ${resultsDir}`); + return null; + } } /** @type {Record} */ @@ -89,6 +98,8 @@ function mergeResults(resultsDir) { let runsPerScenario = 0; let platform = 'linux'; /** @type {string | undefined} */ + let buildMode; + /** @type {string | undefined} */ let baselineBuildVersion; /** @type {string | undefined} */ let threshold; @@ -115,6 +126,7 @@ function mergeResults(resultsDir) { const results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8')); runsPerScenario = results.runsPerScenario || runsPerScenario; platform = results.platform || platform; + buildMode = results.buildMode || buildMode; for (const [scenario, data] of Object.entries(results.scenarios || {})) { mergedScenarios[scenario] = data; } @@ -158,6 +170,7 @@ function mergeResults(resultsDir) { timestamp: new Date().toISOString(), platform, runsPerScenario, + buildMode, scenarios: mergedScenarios, }; @@ -224,11 +237,14 @@ function round2(v) { return Math.round(v * 100) / 100; } * * @param {Record} jsonReport * @param {Record | null} baseline - * @param {{ threshold: number, metricThresholds?: Record, runs: number, baselineBuild?: string, build?: string }} opts + * @param {{ threshold: number, metricThresholds?: Record, runs: number, baselineBuild?: string, build?: string, hasLeakFailure?: boolean }} opts */ function generateUnifiedSummary(jsonReport, baseline, opts) { const baseLabel = opts.baselineBuild || 'baseline'; - const testLabel = opts.build || 'dev (local)'; + const testBuildMode = jsonReport.buildMode || 'dev'; + const testLabel = testBuildMode === 'dev' ? 'dev (local)' + : testBuildMode === 'production' ? 'production (local)' + : opts.build || testBuildMode; const baseLink = formatBuildLink(baseLabel); const testLink = formatBuildLink(testLabel); const compareLink = formatCompareLink(baseLabel, testLabel); @@ -316,17 +332,23 @@ function generateUnifiedSummary(jsonReport, baseline, opts) { // -- Header ---------------------------------------------------------- const hasRegressions = totalRegressions > 0; - const verdictIcon = hasRegressions ? '\u274C' : '\u2705'; - let verdictText; + const hasLeakFailure = !!opts.hasLeakFailure; + const hasFailed = hasRegressions || hasLeakFailure; + const verdictIcon = hasFailed ? '\u274C' : '\u2705'; + const verdictParts = []; if (hasRegressions && totalImprovements > 0) { - verdictText = `${totalRegressions} regression(s), ${totalImprovements} improvement(s)`; + verdictParts.push(`${totalRegressions} regression(s), ${totalImprovements} improvement(s)`); } else if (hasRegressions) { - verdictText = `${totalRegressions} regression(s) detected`; + verdictParts.push(`${totalRegressions} regression(s) detected`); } else if (totalImprovements > 0) { - verdictText = `No regressions \u2014 ${totalImprovements} improvement(s)`; + verdictParts.push(`No regressions \u2014 ${totalImprovements} improvement(s)`); } else { - verdictText = 'No significant changes'; + verdictParts.push('No significant changes'); } + if (hasLeakFailure) { + verdictParts.push('memory leak detected'); + } + const verdictText = verdictParts.join('; '); lines.push(`# ${verdictIcon} Chat Performance: ${verdictText}`); lines.push(''); @@ -508,24 +530,33 @@ function main() { const { report, baseline, baselineBuildVersion } = merged; const scenarioCount = Object.keys(report.scenarios).length; - console.log(`[merge] Merged ${scenarioCount} scenarios from ${fs.readdirSync(opts.resultsDir).filter(d => d.startsWith('perf-results-')).length} groups`); + console.log(`[merge] Merged ${scenarioCount} scenarios from ${fs.readdirSync(opts.resultsDir).filter(d => d.startsWith('perf-results-') || d.startsWith('perf-summary-')).length} groups`); if (baseline) { console.log(`[merge] Baseline: ${baselineBuildVersion || 'unknown'} (${Object.keys(baseline.scenarios).length} scenarios)`); } + // Read leak summary early so we can reflect it in the header verdict + let leakSummaryContent = ''; + let hasLeakFailure = false; + if (opts.leakSummary && fs.existsSync(opts.leakSummary)) { + leakSummaryContent = fs.readFileSync(opts.leakSummary, 'utf-8'); + hasLeakFailure = leakSummaryContent.includes('\u274C'); + console.log(`[merge] Leak summary found (failure: ${hasLeakFailure})`); + } + const summary = generateUnifiedSummary(report, baseline, { threshold: merged.threshold || opts.threshold, metricThresholds: merged.metricThresholds, runs: report.runsPerScenario, baselineBuild: baselineBuildVersion, build: process.env.TEST_COMMIT || undefined, + hasLeakFailure, }); // Append leak summary if available let fullSummary = summary; - if (opts.leakSummary && fs.existsSync(opts.leakSummary)) { - fullSummary += '\n' + fs.readFileSync(opts.leakSummary, 'utf-8'); - console.log('[merge] Appended leak summary'); + if (leakSummaryContent) { + fullSummary += '\n' + leakSummaryContent; } fs.writeFileSync(opts.output, fullSummary); diff --git a/scripts/chat-simulation/test-chat-perf-regression.js b/scripts/chat-simulation/test-chat-perf-regression.js index d93583512e7d7..a71866194d379 100644 --- a/scripts/chat-simulation/test-chat-perf-regression.js +++ b/scripts/chat-simulation/test-chat-perf-regression.js @@ -46,6 +46,7 @@ function parseArgs() { ci: false, noCache: false, force: false, + heapSnapshots: false, /** @type {string[]} */ scenarios: [], /** @type {string | undefined} */ @@ -67,6 +68,7 @@ function parseArgs() { testSettingsOverrides: {}, /** @type {Record} */ baselineSettingsOverrides: {}, + cleanupDiagnostics: false, }; for (let i = 0; i < args.length; i++) { switch (args[i]) { @@ -97,7 +99,9 @@ function parseArgs() { } case '--no-cache': opts.noCache = true; break; case '--force': opts.force = true; break; - case '--ci': opts.ci = true; opts.noCache = true; break; + case '--heap-snapshots': opts.heapSnapshots = true; break; + case '--ci': opts.ci = true; opts.noCache = true; opts.heapSnapshots = true; opts.cleanupDiagnostics = true; break; + case '--cleanup-diagnostics': opts.cleanupDiagnostics = true; break; case '--help': case '-h': console.log([ 'Chat performance benchmark', @@ -123,7 +127,9 @@ function parseArgs() { ' e.g. --setting chat.experimental.incrementalRendering.enabled=true', ' --no-cache Ignore cached baseline data, always run fresh', ' --force Skip build mode mismatch confirmation', - ' --ci CI mode: write Markdown summary to ci-summary.md (implies --no-cache)', + ' --heap-snapshots Take heap snapshots (slow; auto-enabled in --ci mode)', + ' --ci CI mode: write Markdown summary to ci-summary.md (implies --no-cache, --heap-snapshots, --cleanup-diagnostics)', + ' --cleanup-diagnostics Remove heap snapshots, CPU profiles, and traces after each run to save disk space', ' --verbose Print per-run details', '', 'Scenarios: ' + getScenarioIds().join(', '), @@ -355,9 +361,11 @@ function exceedsThreshold(threshold, change, absoluteDelta) { * @param {string} runDir - timestamped run directory for diagnostics * @param {'baseline' | 'test'} role - whether this is a baseline or test run * @param {Record} [settingsOverrides] - custom VS Code settings + * @param {{ heapSnapshots?: boolean }} [runOpts] - additional run options * @returns {Promise} */ -async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, runDir, role, settingsOverrides) { +async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, runDir, role, settingsOverrides, runOpts) { + const takeHeapSnapshots = runOpts?.heapSnapshots ?? false; const { userDataDir, extDir, logsDir } = prepareRunDir(runIndex, mockServer, settingsOverrides); const isDevBuild = !electronPath.includes('.vscode-test') && !electronPath.includes('VSCode-'); // Extract a clean build label from the path. @@ -689,17 +697,7 @@ async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, ru const heapAfter = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage')); const metricsAfter = await cdp.send('Performance.getMetrics'); - // Take heap snapshot - const snapshotPath = path.join(runDiagDir, 'heap.heapsnapshot'); - await cdp.send('HeapProfiler.enable'); - const snapshotChunks = /** @type {string[]} */ ([]); - cdp.on('HeapProfiler.addHeapSnapshotChunk', (/** @type {any} */ params) => { - snapshotChunks.push(params.chunk); - }); - await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); - fs.writeFileSync(snapshotPath, snapshotChunks.join('')); - - // -- Extension host metrics ------------------------------------------ + // -- Extension host metrics (non-snapshot) --------------------------- let extHostHeapUsedBefore = -1; let extHostHeapUsedAfter = -1; let extHostHeapDelta = -1; @@ -733,28 +731,67 @@ async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, ru extHostHeapDeltaPostGC = -1; } - // Take ext host heap snapshot - extHostSnapshotPath = path.join(runDiagDir, 'exthost-heap.heapsnapshot'); - const extSnapshotChunks = /** @type {string[]} */ ([]); - extHostInspector.on('HeapProfiler.addHeapSnapshotChunk', (/** @type {any} */ params) => { - extSnapshotChunks.push(params.chunk); - }); - await extHostInspector.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); - fs.writeFileSync(extHostSnapshotPath, extSnapshotChunks.join('')); - if (verbose) { console.log(` [ext-host] Heap: before=${extHostHeapUsedBefore}MB, after=${extHostHeapUsedAfter}MB, delta=${extHostHeapDelta}MB, deltaPostGC=${extHostHeapDeltaPostGC}MB`); - console.log(` [ext-host] Snapshot saved to ${extHostSnapshotPath}`); } } catch (err) { if (verbose) { console.log(` [ext-host] Error collecting metrics: ${err}`); } - } finally { - extHostInspector.close(); } } + // -- Heap snapshots (opt-in, parallelized) --------------------------- + let snapshotPath = ''; + if (takeHeapSnapshots) { + const snapshotPromises = []; + + // Renderer snapshot + snapshotPromises.push((async () => { + const p = path.join(runDiagDir, 'heap.heapsnapshot'); + await cdp.send('HeapProfiler.enable'); + const chunks = /** @type {string[]} */ ([]); + cdp.on('HeapProfiler.addHeapSnapshotChunk', (/** @type {any} */ params) => { + chunks.push(params.chunk); + }); + await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + fs.writeFileSync(p, chunks.join('')); + return p; + })()); + + // Extension host snapshot (parallel with renderer) + if (extHostInspector && extHostHeapBefore) { + snapshotPromises.push((async () => { + const p = path.join(runDiagDir, 'exthost-heap.heapsnapshot'); + const chunks = /** @type {string[]} */ ([]); + extHostInspector.on('HeapProfiler.addHeapSnapshotChunk', (/** @type {any} */ params) => { + chunks.push(params.chunk); + }); + await extHostInspector.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + fs.writeFileSync(p, chunks.join('')); + return p; + })()); + } + + const snapshotResults = await Promise.all(snapshotPromises); + snapshotPath = snapshotResults[0]; + if (snapshotResults.length > 1) { + extHostSnapshotPath = snapshotResults[1]; + } + + if (verbose) { + console.log(` [debug] Renderer snapshot saved to ${snapshotPath}`); + if (extHostSnapshotPath) { + console.log(` [ext-host] Snapshot saved to ${extHostSnapshotPath}`); + } + } + } + + // Close ext host inspector now that snapshots (if any) are done + if (extHostInspector) { + extHostInspector.close(); + } + // Store partial metrics here so we can combine with trace data after close. /** @param {any} r @param {string} name */ @@ -842,7 +879,10 @@ async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, ru for (const event of traceEvents) { const isGC = event.cat === 'v8.gc' || event.cat === 'devtools.timeline,v8' - || (typeof event.cat === 'string' && event.cat.split(',').some((/** @type {string} */ c) => c.trim() === 'v8.gc')); + || (typeof event.cat === 'string' && event.cat.split(',').some((/** @type {string} */ c) => { + const t = c.trim(); + return t === 'v8.gc' || t === 'disabled-by-default-v8.gc' || t === 'disabled-by-default-v8.gc_stats'; + })); if (!isGC) { continue; } // Only count complete ('X') or duration-begin ('B') events to // avoid double-counting begin/end pairs. @@ -1249,6 +1289,49 @@ function installSignalHandlers() { process.on('SIGTERM', cleanup); } +// -- Diagnostic cleanup ------------------------------------------------------ + +/** + * Remove large diagnostic files (heap snapshots, CPU profiles, traces) from + * a run's metrics to free disk space. Keeps the JSON results data intact. + * @param {RunMetrics} metrics + */ +function cleanupRunDiagnostics(metrics) { + const filesToDelete = [ + metrics.profilePath, + metrics.tracePath, + metrics.snapshotPath, + metrics.extHostProfilePath, + metrics.extHostSnapshotPath, + ]; + for (const filePath of filesToDelete) { + if (filePath && fs.existsSync(filePath)) { + try { + fs.rmSync(filePath, { force: true }); + } catch { + // Ignore cleanup errors + } + } + } +} + +/** + * Clean up diagnostics for all scenarios that did NOT regress. + * Keeps diagnostics for regressed scenarios so they can be investigated. + * @param {Record} allResults - test results by scenario + * @param {Set} regressedScenarios - scenarios that regressed + */ +function cleanupNonRegressedDiagnostics(allResults, regressedScenarios) { + for (const [scenario, runs] of Object.entries(allResults)) { + if (regressedScenarios.has(scenario)) { + continue; + } + for (const metrics of runs) { + cleanupRunDiagnostics(metrics); + } + } +} + // -- Main -------------------------------------------------------------------- async function main() { @@ -1310,7 +1393,9 @@ async function main() { const runIdx = `${scenario}-resume-${prevTestRuns.length + i}`; console.log(`[chat-simulation] Run ${i + 1}/${runsToAdd}...`); try { - const m = await runOnce(testElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'test', { ...opts.settingsOverrides, ...opts.testSettingsOverrides }); + const m = await runOnce(testElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'test', { ...opts.settingsOverrides, ...opts.testSettingsOverrides }, { heapSnapshots: opts.heapSnapshots }); + // Clean up previous run's diagnostics to bound disk usage; keep the latest + if (opts.cleanupDiagnostics && prevTestRuns.length > 0) { cleanupRunDiagnostics(prevTestRuns[prevTestRuns.length - 1]); } prevTestRuns.push(m); if (opts.verbose) { const src = m.hasInternalMarks ? 'internal' : 'client-side'; @@ -1326,7 +1411,9 @@ async function main() { const runIdx = `baseline-${scenario}-resume-${prevBaseRuns.length + i}`; console.log(`[chat-simulation] Run ${i + 1}/${runsToAdd}...`); try { - const m = await runOnce(baselineElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'baseline', { ...opts.settingsOverrides, ...opts.baselineSettingsOverrides }); + const m = await runOnce(baselineElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'baseline', { ...opts.settingsOverrides, ...opts.baselineSettingsOverrides }, { heapSnapshots: opts.heapSnapshots }); + // Clean up previous run's diagnostics to bound disk usage; keep the latest + if (opts.cleanupDiagnostics && prevBaseRuns.length > 0) { cleanupRunDiagnostics(prevBaseRuns[prevBaseRuns.length - 1]); } prevBaseRuns.push(m); } catch (err) { console.error(` Run ${i + 1} failed: ${err}`); } } @@ -1469,7 +1556,12 @@ async function main() { /** @type {RunMetrics[]} */ const newResults = []; for (let i = 0; i < runsNeeded; i++) { - try { newResults.push(await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${existingRuns.length + i}`, runDir, 'baseline', baselineSettings)); } + try { + const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${existingRuns.length + i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots }); + // Clean up previous run's diagnostics to bound disk usage; keep the latest + if (opts.cleanupDiagnostics && newResults.length > 0) { cleanupRunDiagnostics(newResults[newResults.length - 1]); } + newResults.push(m); + } catch (err) { console.error(`[chat-simulation] Baseline run ${i + 1} failed: ${err}`); } } const allRuns = [...existingRuns, ...newResults]; @@ -1495,7 +1587,12 @@ async function main() { /** @type {RunMetrics[]} */ const results = []; for (let i = 0; i < opts.runs; i++) { - try { results.push(await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${i}`, runDir, 'baseline', baselineSettings)); } + try { + const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots }); + // Clean up previous run's diagnostics to bound disk usage; keep the latest + if (opts.cleanupDiagnostics && results.length > 0) { cleanupRunDiagnostics(results[results.length - 1]); } + results.push(m); + } catch (err) { console.error(`[chat-simulation] Baseline run ${i + 1} failed: ${err}`); } } if (results.length > 0) { baselineResults[scenario] = results; } @@ -1574,7 +1671,9 @@ async function main() { for (let i = 0; i < opts.runs; i++) { console.log(`[chat-simulation] Run ${i + 1}/${opts.runs}...`); try { - const metrics = await runOnce(electronPath, scenario, mockServer, opts.verbose, `${scenario}-${i}`, runDir, 'test', testSettings); + const metrics = await runOnce(electronPath, scenario, mockServer, opts.verbose, `${scenario}-${i}`, runDir, 'test', testSettings, { heapSnapshots: opts.heapSnapshots }); + // Clean up previous run's diagnostics to bound disk usage; keep the latest + if (opts.cleanupDiagnostics && results.length > 0) { cleanupRunDiagnostics(results[results.length - 1]); } results.push(metrics); if (opts.verbose) { const src = metrics.hasInternalMarks ? 'internal' : 'client-side'; @@ -1652,7 +1751,12 @@ async function main() { } // -- Baseline comparison --------------------------------------------- - await printComparison(jsonReport, opts); + const regressedScenarios = await printComparison(jsonReport, opts); + + // Clean up diagnostics for scenarios that did not regress + if (opts.cleanupDiagnostics) { + cleanupNonRegressedDiagnostics(allResults, regressedScenarios); + } if (anyFailed) { process.exit(1); } await mockServer.close(); @@ -1660,12 +1764,16 @@ async function main() { /** * Print baseline comparison and exit with code 1 if regressions found. + * Returns the set of scenario IDs that regressed. * @param {Record} jsonReport - * @param {{ baseline?: string, threshold: number, ci?: boolean, runs?: number, baselineBuild?: string, build?: string, resume?: string, metricThresholds?: Record }} opts + * @param {{ threshold: number, metricThresholds?: Record, baseline?: string, ci?: boolean, resume?: string, build?: string, baselineBuild?: string, runs: number, cleanupDiagnostics?: boolean }} opts + * @returns {Promise>} */ async function printComparison(jsonReport, opts) { let regressionFound = false; let inconclusiveFound = false; + /** @type {Set} */ + const regressedScenarios = new Set(); if (opts.baseline && fs.existsSync(opts.baseline)) { const baseline = JSON.parse(fs.readFileSync(opts.baseline, 'utf-8')); console.log(''); @@ -1745,6 +1853,7 @@ async function printComparison(jsonReport, opts) { diffs.push(` ${metric}: ${bas.median}${unit} → ${cur.median}${unit} (${pct}) [info]`); } console.log(` ${scenario}: ${scenarioRegression ? 'FAIL' : 'OK'}`); + if (scenarioRegression) { regressedScenarios.add(scenario); } diffs.forEach(d => console.log(d)); } @@ -1820,6 +1929,7 @@ async function printComparison(jsonReport, opts) { } if (regressionFound) { process.exit(1); } + return regressedScenarios; } main().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/tsconfig.json b/src/tsconfig.json index bfda0ebafc1e5..815c7bfe18355 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -8,6 +8,7 @@ "allowJs": true, "resolveJsonModule": true, "isolatedModules": false, + "skipLibCheck": true, "outDir": "../out/vs", "types": [ "@webgpu/types", diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index 18a6c821411d8..723d88318724e 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -931,6 +931,11 @@ export interface EmitterOptions { * @see setGlobalLeakWarningThreshold */ leakWarningThreshold?: number; + /** + * Human-readable name for the emitter, included in leak warning error + * messages to help identify which emitter is leaking in telemetry. + */ + leakWarningName?: string; /** * Pass in a delivery queue, which is useful for ensuring * in order event delivery across multiple emitters. @@ -1025,12 +1030,13 @@ class LeakageMonitor { this._warnCountdown = threshold * 0.5; const [topStack, topCount] = this.getMostFrequentStack()!; + const emitterName = /^[0-9a-f]+$/i.test(this.name) ? undefined : this.name; const message = `[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`; console.warn(message); console.warn(topStack); const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; - const error = new ListenerLeakError(kind, message, topStack, listenerCount); + const error = new ListenerLeakError(kind, message, topStack, listenerCount, emitterName); this._errorHandler(error); } @@ -1077,11 +1083,14 @@ export class ListenerLeakError 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 leak errors group under the same title in telemetry. + * `message`. When `emitterName` is provided, errors group by emitter name + * and kind in telemetry; otherwise they group by kind alone. */ readonly details: string; - constructor(kind: 'dominated' | 'popular', details: string, stack: string, listenerCount: number) { - super(`potential listener LEAK detected, ${kind}`); + constructor(kind: 'dominated' | 'popular', details: string, stack: string, listenerCount: number, emitterName?: string) { + super(emitterName + ? `[${emitterName}] potential listener LEAK detected, ${kind}` + : `potential listener LEAK detected, ${kind}`); this.name = 'ListenerLeakError'; this.kind = kind; this.listenerCount = listenerCount; @@ -1098,8 +1107,8 @@ export class ListenerLeakError extends Error { // SEVERE error that is logged when having gone way over the configured listener // threshold so that the emitter refuses to accept more listeners export class ListenerRefusalError extends ListenerLeakError { - constructor(kind: 'dominated' | 'popular', details: string, stack: string, listenerCount: number) { - super(kind, details, stack, listenerCount); + constructor(kind: 'dominated' | 'popular', details: string, stack: string, listenerCount: number, emitterName?: string) { + super(kind, details, stack, listenerCount, emitterName); this.name = 'ListenerRefusalError'; } } @@ -1187,7 +1196,7 @@ export class Emitter { constructor(options?: EmitterOptions) { this._options = options; this._leakageMon = (_globalLeakWarningThreshold > 0 || this._options?.leakWarningThreshold) - ? new LeakageMonitor(options?.onListenerError ?? onUnexpectedError, this._options?.leakWarningThreshold ?? _globalLeakWarningThreshold) : + ? new LeakageMonitor(options?.onListenerError ?? onUnexpectedError, this._options?.leakWarningThreshold ?? _globalLeakWarningThreshold, this._options?.leakWarningName) : undefined; this._perfMon = this._options?._profName ? new EventProfiling(this._options._profName) : undefined; this._deliveryQueue = this._options?.deliveryQueue as EventDeliveryQueuePrivate | undefined; @@ -1238,7 +1247,7 @@ export class Emitter { const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1]; 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], this._size); + const error = new ListenerRefusalError(kind, `${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0], this._size, this._options?.leakWarningName); const errorHandler = this._options?.onListenerError || onUnexpectedError; errorHandler(error); diff --git a/src/vs/editor/common/services/languageService.ts b/src/vs/editor/common/services/languageService.ts index dffe6ea3d2f04..57771ef4dca0d 100644 --- a/src/vs/editor/common/services/languageService.ts +++ b/src/vs/editor/common/services/languageService.ts @@ -23,7 +23,7 @@ export class LanguageService extends Disposable implements ILanguageService { private readonly _onDidRequestRichLanguageFeatures = this._register(new Emitter()); public readonly onDidRequestRichLanguageFeatures = this._onDidRequestRichLanguageFeatures.event; - protected readonly _onDidChange = this._register(new Emitter({ leakWarningThreshold: 200 /* https://github.com/microsoft/vscode/issues/119968 */ })); + protected readonly _onDidChange = this._register(new Emitter({ leakWarningThreshold: 200, leakWarningName: 'LanguageService._onDidChange' /* https://github.com/microsoft/vscode/issues/119968 */ })); public readonly onDidChange: Event = this._onDidChange.event; private readonly _requestedBasicLanguages = new Set(); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index c83ff0399dfa5..e7fa4b6fcd1af 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -19,16 +19,42 @@ import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCod import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; -import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; +import type { IClientNotificationMap, ICommandMap, IJsonRpcErrorResponse, IJsonRpcRequest } from '../common/state/protocol/messages.js'; import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../common/state/sessionActions.js'; import { ISessionSummary, ROOT_STATE_URI, StateComponents, type IRootState } from '../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes } from '../common/state/protocol/errors.js'; import { ContentEncoding, type ICreateTerminalParams, type IResolveSessionConfigResult, type ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +const AHP_CLIENT_CONNECTION_CLOSED = -32000; + +export class RemoteAgentHostProtocolError extends Error { + + readonly code: number; + readonly data: unknown | undefined; + + constructor(error: IJsonRpcErrorResponse['error']) { + super(error.message); + this.code = error.code; + this.data = error.data; + } + + static connectionClosed(address: string): RemoteAgentHostProtocolError { + return new RemoteAgentHostProtocolError({ code: AHP_CLIENT_CONNECTION_CLOSED, message: `Connection closed: ${address}` }); + } + + static disposed(address: string): RemoteAgentHostProtocolError { + return new RemoteAgentHostProtocolError({ code: AHP_CLIENT_CONNECTION_CLOSED, message: `Connection disposed: ${address}` }); + } +} + +interface IRemoteAgentHostExtensionCommandMap { + 'shutdown': { params: undefined; result: void }; +} + /** * A protocol-level client for a single remote agent host connection. * Manages the WebSocket transport, handshake, subscriptions, action dispatch, @@ -62,6 +88,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** Pending JSON-RPC requests keyed by request id. */ private readonly _pendingRequests = new Map>(); private _nextRequestId = 1; + private _isClosed = false; + private _closeError: RemoteAgentHostProtocolError | undefined; get clientId(): string { return this._clientId; @@ -87,7 +115,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._transport = transport; this._register(this._transport); this._register(this._transport.onMessage(msg => this._handleMessage(msg))); - this._register(this._transport.onClose(() => this._onDidClose.fire())); + this._register(this._transport.onClose(() => this._handleClose(RemoteAgentHostProtocolError.connectionClosed(this._address)))); this._subscriptionManager = this._register(new AgentSubscriptionManager( this._clientId, @@ -103,12 +131,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC })); } + override dispose(): void { + this._handleClose(RemoteAgentHostProtocolError.disposed(this._address)); + super.dispose(); + } + /** * Connect to the remote agent host and perform the protocol handshake. */ async connect(): Promise { if (isClientTransport(this._transport)) { - await this._transport.connect(); + await this._raceClose(this._transport.connect()); } const result = await this._sendRequest('initialize', { @@ -270,6 +303,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, isRead: s.isRead, isDone: s.isDone, + diffs: s.diffs, })); } @@ -316,7 +350,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._pendingRequests.delete(msg.id); if (hasKey(msg, { error: true })) { this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error); - pending.error(new Error(msg.error.message)); + pending.error(this._toProtocolError(msg.error)); } else { pending.complete(msg.result); } @@ -347,6 +381,34 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } } + private _handleClose(error: RemoteAgentHostProtocolError): void { + if (this._isClosed) { + return; + } + + this._isClosed = true; + this._closeError = error; + this._rejectPendingRequests(error); + this._onDidClose.fire(); + } + + private async _raceClose(promise: Promise): Promise { + if (this._closeError) { + return Promise.reject(this._closeError); + } + + let closeListener = Disposable.None; + const closePromise = new Promise((_resolve, reject) => { + closeListener = this.onDidClose(() => reject(this._closeError)); + }); + + try { + return await Promise.race([promise, closePromise]); + } finally { + closeListener.dispose(); + } + } + /** * Handles reverse RPC requests from the server (e.g. resourceList, * resourceRead). Reads from the local file service and sends a response. @@ -418,6 +480,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** Send a typed JSON-RPC request for a protocol-defined method. */ private _sendRequest(method: M, params: ICommandMap[M]['params']): Promise { + if (this._closeError) { + return Promise.reject(this._closeError); + } + const id = this._nextRequestId++; const deferred = new DeferredPromise(); this._pendingRequests.set(id, deferred); @@ -428,14 +494,28 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } /** Send a JSON-RPC request for a VS Code extension method (not in the protocol spec). */ - private _sendExtensionRequest(method: string, params?: unknown): Promise { + private _sendExtensionRequest(method: M, params?: IRemoteAgentHostExtensionCommandMap[M]['params']): Promise { + if (this._closeError) { + return Promise.reject(this._closeError); + } + const id = this._nextRequestId++; const deferred = new DeferredPromise(); this._pendingRequests.set(id, deferred); - // 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', id, method, params } as unknown as IJsonRpcResponse); - return deferred.p; + const request: IJsonRpcRequest = { jsonrpc: '2.0', id, method, params }; + this._transport.send(request); + return deferred.p as Promise; + } + + private _toProtocolError(error: IJsonRpcErrorResponse['error']): RemoteAgentHostProtocolError { + return new RemoteAgentHostProtocolError(error); + } + + private _rejectPendingRequests(error: RemoteAgentHostProtocolError): void { + for (const pending of this._pendingRequests.values()) { + pending.error(error); + } + this._pendingRequests.clear(); } /** diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts index 8134b3a4e3dde..79c1508ff647d 100644 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { decodeHex, encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; +import { basename } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; @@ -21,7 +22,7 @@ export function buildSessionDbUri(sessionUri: string, toolCallId: string, filePa return URI.from({ scheme: SESSION_DB_SCHEME, authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), - path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}/${part}`, + path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}/${part}/${basename(filePath)}`, }).toString(); } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 182198d3ab11c..74a83a1e191af 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -403,6 +403,7 @@ export class ProtocolServerHandler extends Disposable { workingDirectory: s.workingDirectory?.toString(), isRead: s.isRead, isDone: s.isDone, + diffs: s.diffs ? [...s.diffs] : undefined, }; }); return { items }; diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts new file mode 100644 index 0000000000000..557d1b36978b6 --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { RemoteAgentHostProtocolClient, RemoteAgentHostProtocolError } from '../../browser/remoteAgentHostProtocolClient.js'; +import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; +import type { IAhpServerNotification, IJsonRpcNotification, IJsonRpcRequest, IJsonRpcResponse, IProtocolMessage } from '../../common/state/sessionProtocol.js'; +import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; + +type ProtocolTransportMessage = IProtocolMessage | IAhpServerNotification | IJsonRpcNotification | IJsonRpcResponse | IJsonRpcRequest; + +class TestProtocolTransport extends Disposable implements IProtocolTransport { + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + readonly sentMessages: ProtocolTransportMessage[] = []; + + send(message: ProtocolTransportMessage): void { + this.sentMessages.push(message); + } + + fireMessage(message: IProtocolMessage): void { + this._onMessage.fire(message); + } + + fireClose(): void { + this._onClose.fire(); + } +} + +class TestClientProtocolTransport extends TestProtocolTransport implements IClientTransport { + readonly connectDeferred = new DeferredPromise(); + + connect(): Promise { + return this.connectDeferred.p; + } +} + +class CloseOnDisposeProtocolTransport extends TestProtocolTransport { + override dispose(): void { + this.fireClose(); + super.dispose(); + } +} + +suite('RemoteAgentHostProtocolClient', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createClient(transport = disposables.add(new TestProtocolTransport())): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { + const fileService = disposables.add(new FileService(new NullLogService())); + const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, new NullLogService(), fileService)); + return { client, transport }; + } + + async function assertRemoteProtocolError(promise: Promise, expected: { code: number; message: string; data?: unknown }): Promise { + try { + await promise; + assert.fail('Expected promise to reject'); + } catch (error) { + if (!(error instanceof RemoteAgentHostProtocolError)) { + assert.fail(`Expected RemoteAgentHostProtocolError, got ${String(error)}`); + } + assert.strictEqual(error.code, expected.code); + assert.strictEqual(error.message, expected.message); + assert.deepStrictEqual(error.data, expected.data); + } + } + + test('completes matching response and removes it from pending requests', async () => { + const { client, transport } = createClient(); + const resultPromise = client.resourceList(URI.file('/workspace')); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'resourceList', + params: { uri: URI.file('/workspace').toString() }, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: { entries: [] } }); + assert.deepStrictEqual(await resultPromise, { entries: [] }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: { entries: [{ name: 'late', type: 'file' }] } }); + assert.strictEqual(transport.sentMessages.length, 1); + }); + + test('preserves JSON-RPC error code and data', async () => { + const { client, transport } = createClient(); + const resultPromise = client.resourceRead(URI.file('/missing')); + const data = { uri: URI.file('/missing').toString() }; + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Missing resource', data } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing resource', data }); + }); + + test('ignores response for unknown request id', () => { + const { transport } = createClient(); + + transport.fireMessage({ jsonrpc: '2.0', id: 99, result: null }); + + assert.strictEqual(transport.sentMessages.length, 0); + }); + + test('rejects all pending requests on transport close', async () => { + const { client, transport } = createClient(); + const first = client.resourceList(URI.file('/one')); + const second = client.resourceRead(URI.file('/two')); + let closeCount = 0; + disposables.add(client.onDidClose(() => closeCount++)); + const firstRejected = assertRemoteProtocolError(first, { code: -32000, message: 'Connection closed: test.example:1234' }); + const secondRejected = assertRemoteProtocolError(second, { code: -32000, message: 'Connection closed: test.example:1234' }); + + transport.fireClose(); + transport.fireClose(); + + await firstRejected; + await secondRejected; + assert.strictEqual(closeCount, 1); + }); + + test('rejects pending requests on dispose', async () => { + const { client } = createClient(); + const resultPromise = client.resourceList(URI.file('/workspace')); + const rejected = assertRemoteProtocolError(resultPromise, { code: -32000, message: 'Connection disposed: test.example:1234' }); + + client.dispose(); + + await rejected; + }); + + test('dispose rejection wins when transport emits close while disposing', async () => { + const transport = disposables.add(new CloseOnDisposeProtocolTransport()); + const { client } = createClient(transport); + const resultPromise = client.resourceList(URI.file('/workspace')); + const rejected = assertRemoteProtocolError(resultPromise, { code: -32000, message: 'Connection disposed: test.example:1234' }); + + client.dispose(); + + await rejected; + }); + + test('late response after close does not complete rejected request', async () => { + const { client, transport } = createClient(); + const resultPromise = client.resourceList(URI.file('/workspace')); + const rejected = assertRemoteProtocolError(resultPromise, { code: -32000, message: 'Connection closed: test.example:1234' }); + + transport.fireClose(); + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: { entries: [] } }); + + await rejected; + }); + + test('rejects requests started after transport close', async () => { + const { client, transport } = createClient(); + + transport.fireClose(); + + await assertRemoteProtocolError(client.resourceList(URI.file('/workspace')), { code: -32000, message: 'Connection closed: test.example:1234' }); + assert.strictEqual(transport.sentMessages.length, 0); + }); + + test('rejects requests started after dispose', async () => { + const { client, transport } = createClient(); + + client.dispose(); + + await assertRemoteProtocolError(client.resourceList(URI.file('/workspace')), { code: -32000, message: 'Connection disposed: test.example:1234' }); + assert.strictEqual(transport.sentMessages.length, 0); + }); + + test('rejects connect when transport closes before connect completes', async () => { + const transport = disposables.add(new TestClientProtocolTransport()); + const { client } = createClient(transport); + const rejected = assertRemoteProtocolError(client.connect(), { code: -32000, message: 'Connection closed: test.example:1234' }); + + transport.fireClose(); + transport.connectDeferred.complete(); + + await rejected; + assert.strictEqual(transport.sentMessages.length, 0); + }); + + test('rejects connect when disposed before transport connect completes', async () => { + const transport = disposables.add(new TestClientProtocolTransport()); + const { client } = createClient(transport); + const rejected = assertRemoteProtocolError(client.connect(), { code: -32000, message: 'Connection disposed: test.example:1234' }); + + client.dispose(); + + await rejected; + assert.strictEqual(transport.sentMessages.length, 0); + }); + + test('sends shutdown as a JSON-RPC request shape', async () => { + const { client, transport } = createClient(); + const resultPromise = client.shutdown(); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'shutdown', + params: undefined, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); + await resultPromise; + }); + + test('rejects shutdown with structured JSON-RPC error', async () => { + const { client, transport } = createClient(); + const resultPromise = client.shutdown(); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.TurnInProgress, message: 'Turn in progress' } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.TurnInProgress, message: 'Turn in progress' }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts index 5f633d6dbcc3d..d17dfff9433a2 100644 --- a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -164,4 +164,16 @@ suite('buildSessionDbUri / parseSessionDbUri', () => { assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1'), undefined); assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1&filePath=/f&part=middle'), undefined); }); + + test('URI path ends with the basename of the file', () => { + const uri = buildSessionDbUri('copilot:/s1', 'tc-1', '/workspace/src/index.ts', 'before'); + const parsed = URI.parse(uri); + assert.ok(parsed.path.endsWith('/index.ts')); + }); + + test('URI path ends with basename for files with spaces and special chars', () => { + const uri = buildSessionDbUri('copilot:/s1', 'tc-1', '/work space/file (1).ts', 'after'); + const parsed = URI.parse(uri); + assert.ok(parsed.path.endsWith('/file (1).ts')); + }); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts index 09b88790312ff..bc9d8ac4e16aa 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -30,8 +30,9 @@ import { URI } from '../../../../../base/common/uri.js'; import type { ISessionToolCallStartAction } from '../../../common/state/protocol/actions.js'; import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type ITerminalState, type IToolResultContent, type IToolResultSubagentContent } from '../../../common/state/sessionState.js'; -import type { ISessionAddedNotification, ISessionInputRequestedAction, ISessionResponsePartAction, ISessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; +import { ResponsePartKind, ROOT_STATE_URI, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type ITerminalState, type IToolResultContent, type IToolResultSubagentContent } from '../../../common/state/sessionState.js'; +import type { IRootState } from '../../../common/state/protocol/state.js'; +import type { IRootAgentsChangedAction, ISessionAddedNotification, ISessionInputRequestedAction, ISessionResponsePartAction, ISessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; import { getActionEnvelope, @@ -74,8 +75,8 @@ async function createRealSessionFull(c: TestProtocolClient, clientId: string, tr await c.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); - const sessionUri = URI.from({ scheme: 'copilot', path: `/real-test-${Date.now()}` }).toString(); - await c.call('createSession', { session: sessionUri, provider: 'copilot', workingDirectory }, 30_000); + const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-test-${Date.now()}` }).toString(); + await c.call('createSession', { session: sessionUri, provider: 'copilotcli', workingDirectory }, 30_000); const notif = await c.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', @@ -432,8 +433,8 @@ function terminalText(state: ITerminalState): string { await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-workdir' }); await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }); - const sessionUri = URI.from({ scheme: 'copilot', path: `/real-test-wd-${Date.now()}` }).toString(); - await client.call('createSession', { session: sessionUri, provider: 'copilot', workingDirectory: workingDirUri }); + const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-test-wd-${Date.now()}` }).toString(); + await client.call('createSession', { session: sessionUri, provider: 'copilotcli', workingDirectory: workingDirUri }); // 1. Verify workingDirectory in the sessionAdded notification const addedNotif = await client.waitForNotification(n => @@ -476,10 +477,10 @@ function terminalText(state: ITerminalState): string { await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-worktree' }); await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }); - const sessionUri = URI.from({ scheme: 'copilot', path: `/real-test-wt-${Date.now()}` }).toString(); + const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-test-wt-${Date.now()}` }).toString(); await client.call('createSession', { session: sessionUri, - provider: 'copilot', + provider: 'copilotcli', workingDirectory: workingDirUri, config: { isolation: 'worktree', branch: defaultBranch }, }); @@ -711,4 +712,52 @@ function terminalText(state: ITerminalState): string { `subagent session should contain at least one inner tool call, got ${subagentStarts.length}. ` + `Parent tool calls: ${JSON.stringify(parentStarts.map(a => a.toolName))}`); }); + + // ---- Model discovery ----------------------------------------------------- + + test('listModels returns well-shaped model entries after authenticate', async function () { + this.timeout(60_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-list-models' }, 30_000); + + // Subscribe to root state *before* authenticating so we can observe + // the agentsChanged action that carries the populated model list. + const rootResult = await client.call('subscribe', { resource: ROOT_STATE_URI }, 30_000); + const initial = rootResult.snapshot.state as IRootState; + const copilotAgent = initial.agents.find(a => a.provider === 'copilotcli'); + assert.ok(copilotAgent, `Expected copilotcli agent in root state, got: ${initial.agents.map(a => a.provider).join(', ')}`); + + await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); + + // Models are loaded asynchronously after authenticate. Wait for the + // agentsChanged action that populates them. + const notif = await client.waitForNotification(n => { + if (!isActionNotification(n, 'root/agentsChanged')) { + return false; + } + const action = getActionEnvelope(n).action as IRootAgentsChangedAction; + const agent = action.agents.find(a => a.provider === 'copilotcli'); + return !!agent && agent.models.length > 0; + }, 30_000); + + const action = getActionEnvelope(notif).action as IRootAgentsChangedAction; + const agent = action.agents.find(a => a.provider === 'copilotcli')!; + + assert.ok(agent.models.length > 0, 'Expected at least one model from listModels'); + + // Assert every model has the shape CopilotAgent._listModels produces. + // If the SDK changes and any required field becomes undefined (as + // happened with max_context_window_tokens in @github/copilot@1.0.34), + // this loop surfaces the exact offending model instead of letting + // _refreshModels silently swallow the TypeError and set models=[]. + for (const model of agent.models) { + assert.strictEqual(typeof model.id, 'string', `model.id should be a string: ${JSON.stringify(model)}`); + assert.ok(model.id.length > 0, `model.id should be non-empty: ${JSON.stringify(model)}`); + assert.strictEqual(typeof model.name, 'string', `model.name should be a string: ${JSON.stringify(model)}`); + assert.strictEqual(model.provider, 'copilotcli', `model.provider should be copilotcli: ${JSON.stringify(model)}`); + assert.strictEqual(typeof model.maxContextWindow, 'number', `model.maxContextWindow should be a number: ${JSON.stringify(model)}`); + assert.ok(model.maxContextWindow && model.maxContextWindow > 0, `model.maxContextWindow should be positive: ${JSON.stringify(model)}`); + assert.ok(model.supportsVision === undefined || typeof model.supportsVision === 'boolean', `model.supportsVision should be boolean or undefined: ${JSON.stringify(model)}`); + } + }); }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 29e1162661e72..aab3c61cc0c88 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -367,6 +367,50 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual(result.items.map(item => item.project), [undefined]); }); + test('listSessions includes diffs with before/after URIs and content refs', async () => { + agentService.listedSessions.push({ + session: URI.parse(sessionUri), + startTime: 1000, + modifiedTime: 2000, + summary: 'Session With Diffs', + diffs: [ + { + before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } }, + after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } }, + diff: { added: 5, removed: 2 }, + }, + { + after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } }, + }, + { + before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } }, + }, + ], + }); + + const transport = connectClient('client-list-diffs'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + + transport.simulateMessage(request(2, 'listSessions')); + const resp = await responsePromise; + + const result = (resp as unknown as { result: IListSessionsResult }).result; + assert.deepStrictEqual(result.items[0].diffs, [ + { + before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } }, + after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } }, + diff: { added: 5, removed: 2 }, + }, + { + after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } }, + }, + { + before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } }, + }, + ]); + }); + test('createSession returns null and broadcasts project in sessionAdded summary', async () => { const transport = connectClient('client-create'); transport.sent.length = 0; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index a392fb01cb439..2939e4af4370f 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -1017,10 +1017,18 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic lastSessionWindows.push(this.windowsStateHandler.state.lastActiveWindow); } + const agentSessionsWorkspaceUri = this.environmentMainService.agentSessionsWorkspace; + const pathsToOpen = await Promise.all(lastSessionWindows.map(async lastSessionWindow => { // Workspaces if (lastSessionWindow.workspace) { + // Never restore the agents window from the previous session. + // It is only opened explicitly via `--agents`; otherwise a + // previously-opened agents workspace would "stick" and reopen on every launch. + if (agentSessionsWorkspaceUri && isEqual(lastSessionWindow.workspace.configPath, agentSessionsWorkspaceUri)) { + return undefined; + } const pathToOpen = await this.resolveOpenable({ workspaceUri: lastSessionWindow.workspace.configPath }, { remoteAuthority: lastSessionWindow.remoteAuthority, rejectTransientWorkspaces: true /* https://github.com/microsoft/vscode/issues/119695 */ }); if (isWorkspacePathToOpen(pathToOpen)) { return pathToOpen; diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index d9d5c9b08a128..ccc341a089bb7 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -104,6 +104,7 @@ The Agent Sessions titlebar includes a custom left toolbar that appears after th | Action | ID | Location | Behavior | |--------|-----|----------|----------| | Toggle Sidebar | `workbench.action.agentToggleSidebarVisibility` | Left toolbar (`TitleBarLeft`) | Toggles primary sidebar visibility | +| Agent Host Filter | `sessions.agentHostFilter.pick` | Left toolbar (`TitleBarLeft`) | Dropdown indicator of the host the workbench is scoped to; lets the user switch hosts or pick "All Hosts". Visible when at least one remote agent host is known (`sessions.hasAgentHosts`). Renders via a custom `HostFilterActionViewItem`. | | Run Script | `workbench.action.agentSessions.runScript` | Right toolbar (`TitleBarRight`) | Split button: runs configured script or shows configure dialog | | Open... | (submenu) | Right toolbar (`TitleBarRight`) | Split button submenu: Open Terminal, Open in VS Code | | Toggle Secondary Sidebar | `workbench.action.agentToggleSecondarySidebarVisibility` | Right toolbar (`TitleBarRight`) | Toggles auxiliary bar visibility | @@ -176,7 +177,7 @@ This structure places the sidebar at the root level spanning the full window hei | Panel | 300px height | | Titlebar | Determined by `minimumHeight` (~30px) | -The sessions sidebar can be resized down to a minimum width of 170px. +The sessions sidebar can be resized down to a minimum width of 170px (desktop) or 270px (web, sized to fit the titlebar's left toolbar which includes the host filter combo). ### 4.3 Editor Modal diff --git a/src/vs/sessions/SESSIONS_PROVIDER.md b/src/vs/sessions/SESSIONS_PROVIDER.md index 1063819e6c0c5..451ac8bd3d1da 100644 --- a/src/vs/sessions/SESSIONS_PROVIDER.md +++ b/src/vs/sessions/SESSIONS_PROVIDER.md @@ -79,7 +79,7 @@ The common session interface exposed by all providers. It is a self-contained fa - `repositories: ISessionRepository[]` — One or more repositories - `requiresWorkspaceTrust: boolean` — Whether workspace trust is required to operate -**Workspace label and session grouping:** The sessions list groups sessions by `workspace.label`. Sessions whose workspace is `undefined` or whose label is empty appear under "Unknown". For the `CopilotChatSessionsProvider`, the workspace label is derived from session metadata via `getRepositoryName()` (in `agentSessionsViewer.ts`), which checks these metadata keys in priority order: `remoteAgentHost`, `owner`+`name`, `repositoryNwo`, `repository`, `repositoryUrl`, `repositoryPath`, `worktreePath`, `workingDirectoryPath`, then `badge`. Extension-side `ChatSessionContentProvider` implementations must set `item.metadata` with at least `workingDirectoryPath` (for local sessions) so that sessions are grouped correctly. +**Workspace label and session grouping:** The sessions list groups sessions by `workspace.label`. Sessions whose workspace is `undefined` or whose label is empty appear under "Unknown". For the `CopilotChatSessionsProvider`, the workspace label is derived from session metadata via `getRepositoryName()` (in `agentSessionsViewer.ts`), which checks these metadata keys in priority order: `remoteAgentHost`, `owner`+`name`, `repositoryNwo`, `repository`, `repositoryUrl`, `repositoryPath`, `worktreePath`, `workingDirectoryPath`, then `badge`. See the [ChatSessionItem Metadata Contract](#chatsessionitem-metadata-contract) section below for full details. **`ISessionRepository`** — A repository within a workspace: - `uri: URI` — Source repository URI (`file://` or `github-remote-file://`) @@ -353,6 +353,78 @@ Agent session state changes (turn complete, status update, etc.) --- +## ChatSessionItem Metadata Contract + +**File (type definition):** `src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts` +**File (metadata consumer):** `src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts` (`getRepositoryName()`) +**File (adapter consumer):** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts` (`AgentSessionAdapter._buildWorkspace()`) + +Extensions that implement a `ChatSessionItemController` or `ChatSessionContentProvider` create `ChatSessionItem` objects. These items have a `metadata` property (a `Record`) that the sessions list uses to determine workspace grouping, repository information, and display labels. + +### Why This Matters + +The sessions list groups sessions by workspace folder. `AgentSessionAdapter._buildWorkspace()` reads `item.metadata` to construct an `ISessionWorkspace` — specifically its `label`, which determines which section the session appears under. **If `metadata` is missing or lacks a recognizable key, the session appears under "Unknown".** + +### Required Metadata + +Every `ChatSessionItem` created by a controller — whether from `newChatSessionItemHandler`, `forkHandler`, or `_createChatSessionItem` during refresh — **must** set `item.metadata` with at least one of the keys listed below so the session groups correctly. + +For local sessions, the minimum is: + +```typescript +item.metadata = { workingDirectoryPath: '/absolute/path/to/workspace' }; +``` + +### Metadata Key Priority + +`getRepositoryName()` checks metadata keys in this priority order to derive the workspace label. The first match wins: + +| Priority | Key(s) | Used By | Example | +|----------|--------|---------|---------| +| 1 | `remoteAgentHost` + `workingDirectoryPath` | Remote agent host sessions | `"myproject [dev-box]"` | +| 2 | `owner` + `name` | Cloud sessions | `"my-repo"` | +| 3 | `repositoryNwo` | Sessions with `owner/repo` format | `"repo"` from `"owner/repo"` | +| 4 | `repository` | Git remote URL or `owner/repo` string | Parsed repo name | +| 5 | `repositoryUrl` | Full GitHub URL | Parsed repo name | +| 6 | `repositoryPath` | Repository directory path | Basename of path | +| 7 | `worktreePath` | Git worktree path | Basename of path | +| 8 | `workingDirectoryPath` | Working directory (fallback) | Basename of path | +| 9 | _(badge fallback)_ | `session.badge` value | Parsed from badge string | + +### Common Patterns + +**Local sessions (Copilot CLI, Claude):** Set `workingDirectoryPath` to the workspace folder's absolute path. This is the most common case and the minimum required for correct grouping. + +```typescript +// In newChatSessionItemHandler or forkHandler: +const item = controller.createChatSessionItem(uri, prompt); +item.metadata = { workingDirectoryPath: '/Users/me/my-project' }; +``` + +**Cloud sessions:** Set `owner` and `name` for GitHub repository identification. + +```typescript +item.metadata = { owner: 'microsoft', name: 'vscode' }; +``` + +**Remote agent host sessions:** Set `remoteAgentHost` and `workingDirectoryPath` for composite labels. + +```typescript +item.metadata = { remoteAgentHost: 'dev-box', workingDirectoryPath: '/home/user/project' }; +``` + +### Checklist for New Controller Implementations + +When implementing a `ChatSessionItemController`, ensure metadata is set in **all** code paths that create `ChatSessionItem` objects: + +1. **`newChatSessionItemHandler`** — Called when the user sends the first message from the welcome chat. Resolve the workspace folder from `context.inputState` (look for the folder option group) and set `item.metadata`. +2. **`forkHandler`** — Called when forking an existing session. Copy or resolve metadata from the parent session. +3. **Refresh/sync handlers** — When rebuilding items from persisted sessions (e.g., `_refreshItems`), set metadata from the session's stored state. + +Missing metadata in **any** of these paths causes the session to appear under "Unknown" in the sessions list. + +--- + ## Context Keys ### Session Provider Context Keys (managed by `SessionsManagementService`) diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 92d76e7826e5c..99f4919fdf7ec 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -115,7 +115,7 @@ export class ChatCompositeBar extends Disposable { tab.classList.toggle('untitled', status === SessionStatus.Untitled); })); - // Close action bar — only for non-main chats, visible on hover/active when untitled + // Remove action bar — only for non-main chats, visible on hover if (!isMainChat) { const closeAction = this._tabDisposables.add(new Action( 'chatCompositeBar.closeChat', @@ -163,13 +163,6 @@ export class ChatCompositeBar extends Disposable { } })); - const deleteAction = this._tabDisposables.add(new Action('sessionCompositeBar.deleteChat', localize('deleteChat', "Delete"), undefined, !isMainChat, async () => { - const session = this._sessionsManagementService.activeSession.get(); - if (session) { - await this._sessionsManagementService.deleteChat(session, chat.resource); - } - })); - this._tabDisposables.add(addDisposableListener(tab, EventType.CONTEXT_MENU, (e: MouseEvent) => { // No context menu for untitled chats if (chat.status.get() === SessionStatus.Untitled) { @@ -183,7 +176,6 @@ export class ChatCompositeBar extends Disposable { getAnchor: () => event, getActions: () => [ renameAction, - deleteAction, ] }); })); diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 3c35cb7ae39e4..7b40db913d8b1 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -54,8 +54,9 @@ background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 5%, transparent); } -.chat-composite-bar-tab.untitled { - padding-right: 4px; +/* Reduce right padding when close button is present to avoid excess spacing */ +.chat-composite-bar-tab:has(.chat-composite-bar-tab-actions) { + padding-right: 2px; } /* Hide the underline indicator — mirrors auxiliarybar active-item-indicator hiding */ @@ -68,9 +69,9 @@ outline-offset: -1px; } -/* Close action bar — hidden by default, shown on hover/active for untitled chats only */ +/* Remove action bar — always occupies space; opacity toggled like editor tabs */ .chat-composite-bar-tab-actions { - display: none; + display: flex; margin-left: 2px; } @@ -79,11 +80,13 @@ height: 16px; border-radius: 4px; color: var(--chat-tab-inactive-foreground, currentColor); + opacity: 0; } -.chat-composite-bar-tab.untitled:hover .chat-composite-bar-tab-actions, -.chat-composite-bar-tab.untitled.active .chat-composite-bar-tab-actions { - display: flex; +.chat-composite-bar-tab.active .chat-composite-bar-tab-actions .action-label, +.chat-composite-bar-tab:hover .chat-composite-bar-tab-actions .action-label, +.chat-composite-bar-tab-actions .action-label:focus { + opacity: 1; } .chat-composite-bar-tab-actions .action-item .action-label:hover { diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 41edffffa3c87..1ba528f667341 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monaco-workbench .part.titlebar > .sessions-titlebar-container { +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container { justify-content: initial; } -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { display: flex; height: 100%; align-items: center; @@ -18,7 +18,7 @@ justify-content: flex-start; } -.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-center { +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-center { order: 1; width: auto; flex-grow: 0; @@ -28,21 +28,21 @@ justify-content: flex-start; } -.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-left { width: fit-content; flex-grow: 0; } -.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center { +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-center { flex: 1; max-width: none; } -.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center .window-title { +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-center .window-title { margin: unset; } -.agent-sessions-workbench.monaco-workbench.mac .part.titlebar > .sessions-titlebar-container > .titlebar-right { +.agent-sessions-workbench.monaco-workbench.mac .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-right { order: 2; width: fit-content; flex-grow: 0; @@ -90,7 +90,7 @@ .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { display: none; padding-left: 8px; - flex-grow: 0; + flex-grow: 1; flex-shrink: 0; text-align: center; position: relative; @@ -102,7 +102,7 @@ .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { display: flex; - justify-content: center; + justify-content: flex-start; align-items: center; } @@ -114,6 +114,14 @@ display: flex; } +/* Allow the action bar inside the left toolbar to fill the container so the host filter combo can center itself in the remaining space. */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container > .monaco-action-bar, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container > .monaco-action-bar > .actions-container { + flex: 1 1 auto; + min-width: 0; +} + /* Remove the titlebar shadow in agent sessions */ .agent-sessions-workbench.monaco-workbench .part.titlebar { box-shadow: none; diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index c0cbf4789da29..ca6bd4161b737 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -38,7 +38,7 @@ import { isFullscreen, onDidChangeFullscreen } from '../../../base/browser/brows import { mainWindow } from '../../../base/browser/window.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { hasNativeTitlebar, getTitleBarStyle } from '../../../platform/window/common/window.js'; -import { isMacintosh, isNative } from '../../../base/common/platform.js'; +import { isMacintosh, isNative, isWeb } from '../../../base/common/platform.js'; /** * Sidebar part specifically for agent sessions workbench. @@ -68,7 +68,10 @@ export class SidebarPart extends AbstractPaneCompositePart { //#region IView - readonly minimumWidth: number = 170; + // On web the titlebar hosts an additional host filter combo alongside the + // sidebar toggle; use a wider minimum so those controls always fit within + // the sidebar's rendered area (below this the sidebar snaps closed). + readonly minimumWidth: number = isWeb ? 270 : 170; readonly maximumWidth: number = Number.POSITIVE_INFINITY; readonly minimumHeight: number = 0; readonly maximumHeight: number = Number.POSITIVE_INFINITY; diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 16dc8ea7c2697..11192582acecc 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -21,6 +21,20 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { readonly remoteAddress?: string; /** Output channel ID for remote provider logs. */ outputChannelId?: string; + /** + * Establish (or re-establish) the connection for this host on demand. + * Tears down any existing connection first. Present on remote providers + * that manage their own transport (e.g. tunnel relay); providers that + * use the generic {@link IRemoteAgentHostService} reconnect flow may + * leave this undefined. + */ + connect?(): Promise; + /** + * Tear down the active connection for this host without forgetting the + * entry. A subsequent {@link connect} call should be able to re-establish + * it. Present on remote providers that manage their own transport. + */ + disconnect?(): Promise; // -- Dynamic Session Config -- diff --git a/src/vs/sessions/common/agentHostDiffs.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts similarity index 57% rename from src/vs/sessions/common/agentHostDiffs.ts rename to src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts index 5b5949aeaee7d..904730c6f82f7 100644 --- a/src/vs/sessions/common/agentHostDiffs.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isDefined } from '../../base/common/types.js'; -import { URI } from '../../base/common/uri.js'; -import { SessionStatus as ProtocolSessionStatus } from '../../platform/agentHost/common/state/protocol/state.js'; -import { ISessionFileDiff } from '../../platform/agentHost/common/state/sessionState.js'; -import { SessionStatus } from '../services/sessions/common/session.js'; +import { isDefined } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { ISessionFileDiff } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { IChatSessionFileChange } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { SessionStatus } from '../../../services/sessions/common/session.js'; /** * Maps the protocol-layer session status bitset to the UI-layer @@ -23,13 +24,8 @@ export function mapProtocolStatus(protocol: ProtocolSessionStatus): SessionStatu if (protocol & ProtocolSessionStatus.Error) { return SessionStatus.Error; } - return SessionStatus.Completed; -} -export interface IFileChange { - readonly modifiedUri: URI; - readonly insertions: number; - readonly deletions: number; + return SessionStatus.Completed; } /** @@ -38,14 +34,26 @@ export interface IFileChange { * @param mapUri Optional URI mapper applied after parsing. The remote agent * host provider uses this to rewrite `file:` URIs into agent-host URIs. */ -export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): IFileChange[] { +export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): IChatSessionFileChange[] { return diffs.map(d => { const uri = d.after?.uri || d.before?.uri; if (!uri) { return undefined; } + + const modifiedUri = mapUri ? mapUri(URI.parse(uri)) : URI.parse(uri); + + // Use the before-content reference URI so the diff editor can + // fetch the snapshot of the file *before* the session's edits. + let originalUri: URI | undefined; + if (d.before?.content?.uri) { + const parsed = URI.parse(d.before.content.uri); + originalUri = mapUri ? mapUri(parsed) : parsed; + } + return { - modifiedUri: mapUri ? mapUri(URI.parse(uri)) : URI.parse(uri), + modifiedUri, + originalUri, insertions: d.diff?.added ?? 0, deletions: d.diff?.removed ?? 0, }; @@ -56,7 +64,7 @@ export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri * Returns `true` when the current file changes already * match the incoming diffs, avoiding unnecessary observable updates. */ -export function diffsEqual(current: readonly IFileChange[], diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): boolean { +export function diffsEqual(current: readonly IChatSessionFileChange[], diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): boolean { if (current.length !== diffs.length) { return false; } @@ -72,6 +80,18 @@ export function diffsEqual(current: readonly IFileChange[], diffs: readonly ISes if (c.modifiedUri.toString() !== diffUri.toString() || c.insertions !== (d.diff?.added ?? 0) || c.deletions !== (d.diff?.removed ?? 0)) { return false; } + + const beforeContentUri = d.before?.content?.uri; + const currentOriginal = c.originalUri?.toString(); + if (beforeContentUri) { + const parsedBefore = URI.parse(beforeContentUri); + const mappedBefore = mapUri ? mapUri(parsedBefore) : parsedBefore; + if (currentOriginal !== mappedBefore.toString()) { + return false; + } + } else if (currentOriginal) { + return false; + } } return true; } diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index cb8b0bdc4e46a..8dec1c3b0b710 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -24,7 +24,7 @@ import { IChatSendRequestOptions, IChatService } from '../../../../workbench/con import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { diffsEqual, diffsToChanges, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; +import { diffsEqual, diffsToChanges, mapProtocolStatus } from './agentHostDiffs.js'; import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index b1bbd469e9fe5..c3b3588093f4c 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -19,6 +19,7 @@ import { ILanguageModelsService } from '../../../../workbench/contrib/chat/commo import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; +import { toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; const LOCAL_PROVIDER_ID = 'local-agent-host'; const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; @@ -109,24 +110,33 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide return { description: this._localDescription, buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => - LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory), + LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, this._localLabel), }; } protected _formatSessionTypeLabel(agentLabel: string): string { - return localize('localAgentHostSessionType', "{0} [{1}]", agentLabel, this._localLabel); + // Use the unadorned agent label (e.g. "Copilot") rather than tagging it + // with `[Local]`. The session type id is shared with the extension-host + // Copilot CLI provider, so the filter menu / new-session picker entry + // covers both sets of sessions; the `[Local]` tag belongs on the + // per-session workspace label, not the type label. + return agentLabel; + } + + protected override _diffUriMapper(): (uri: URI) => URI { + return uri => toAgentHostUri(uri, 'local'); } // -- Workspaces ---------------------------------------------------------- - static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined): ISessionWorkspace | undefined { - return buildAgentHostSessionWorkspace(project, workingDirectory, { fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true }); + static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { + return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true }); } resolveWorkspace(repositoryUri: URI): ISessionWorkspace { const folderName = basename(repositoryUri) || repositoryUri.path; return { - label: folderName, + label: `${folderName} [${this._localLabel}]`, icon: Codicon.folder, repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: true, diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 0aac0143df763..ebf61ebafa0ce 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -283,13 +283,13 @@ suite('LocalAgentHostSessionsProvider', () => { // the same agent (e.g. `copilotcli`) shares one session type across // local and remote hosts and the standalone Copilot CLI provider. assert.strictEqual(provider.sessionTypes[0].id, 'copilotcli'); - assert.strictEqual(provider.sessionTypes[0].label, 'Copilot [Local]'); + assert.strictEqual(provider.sessionTypes[0].label, 'Copilot'); }); test('session types update when the local host advertises additional agents', () => { const provider = createProvider(disposables, agentHost); assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ - { id: 'copilotcli', label: 'Copilot [Local]' }, + { id: 'copilotcli', label: 'Copilot' }, ]); let changes = 0; @@ -303,8 +303,8 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(changes, 1); // The logical sessionType id is the agent provider name itself. assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ - { id: 'copilotcli', label: 'Copilot [Local]' }, - { id: 'openai', label: 'OpenAI [Local]' }, + { id: 'copilotcli', label: 'Copilot' }, + { id: 'openai', label: 'OpenAI' }, ]); }); @@ -334,12 +334,12 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Workspace resolution ------- - test('resolveWorkspace builds workspace from URI', () => { + test('resolveWorkspace builds workspace from URI with [Local] tag', () => { const provider = createProvider(disposables, agentHost); const uri = URI.parse('file:///home/user/project'); const ws = provider.resolveWorkspace(uri); - assert.strictEqual(ws.label, 'project'); + assert.strictEqual(ws.label, 'project [Local]'); assert.strictEqual(ws.repositories.length, 1); assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString()); assert.strictEqual(ws.requiresWorkspaceTrust, true); @@ -498,12 +498,27 @@ suite('LocalAgentHostSessionsProvider', () => { repository: workspace?.repositories[0]?.uri.toString(), workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(), }, { - label: 'vscode', + label: 'vscode [Local]', repository: projectUri.toString(), workingDirectory: workingDirectory.toString(), }); })); + test('listed session with only workingDirectory (no project) shows folder name with [Local] tag', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const workingDirectory = URI.file('/home/user/standalone-folder'); + agentHost.addSession(createSession('wd-only-1', { + summary: 'WD-only Session', + workingDirectory, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const workspace = provider.getSessions()[0].workspace.get(); + assert.strictEqual(workspace?.label, 'standalone-folder [Local]'); + })); + test('uses model metadata as selected model for listed sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('model-1', { summary: 'Model Session', model: 'claude-sonnet-4.5' })); @@ -566,7 +581,7 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(session.providerId, provider.id); assert.strictEqual(session.status.get(), SessionStatus.Untitled); assert.ok(session.workspace.get()); - assert.strictEqual(session.workspace.get()?.label, 'my-project'); + assert.strictEqual(session.workspace.get()?.label, 'my-project [Local]'); assert.strictEqual(session.sessionType, provider.sessionTypes[0].id); assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} }); }); @@ -593,7 +608,7 @@ suite('LocalAgentHostSessionsProvider', () => { }, { listedSessions: 0, resolvedResource: session.resource.toString(), - resolvedWorkspaceLabel: 'my-project', + resolvedWorkspaceLabel: 'my-project [Local]', }); }); @@ -756,7 +771,7 @@ suite('LocalAgentHostSessionsProvider', () => { const workspace = wsSession!.workspace.get(); assert.ok(workspace); - assert.strictEqual(workspace!.label, 'myrepo'); + assert.strictEqual(workspace!.label, 'myrepo [Local]'); })); test('session adapter without working directory has no workspace', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 00868b5b63b2e..dfdd722418882 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -304,54 +304,32 @@ text-decoration: none !important; } -/* Gradient ring at rest, drawn on the wrapper so the button's own - ::before (codicon glyph) is not overridden. Gated behind the experimental - `sessions.experimental.sendButtonGradient` setting via the - `.sessions-experimental-send-button-gradient` class on the workbench root. */ -.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button::before { - content: ''; - position: absolute; - inset: 0; - border-radius: 4px; - padding: 1px; - background: conic-gradient(from var(--chat-send-button-anim-angle), - var(--vscode-chat-inputWorkingBorderColor1), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor3), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor1)); - -webkit-mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - mask-composite: exclude; - pointer-events: none; - transition: opacity 250ms ease; - z-index: 2; - /* Idle: very slowly rotate the gradient ring. */ +/* Idle: fill the entire button with a slowly rotating conic gradient (no + border ring). Gated behind the experimental `sessions.experimental.sendButtonGradient` + setting via the `.sessions-experimental-send-button-gradient` class on + the workbench root. + + Colors are darkened to match the hover treatment (60% mixed with input + background), and the conic stops are asymmetric so the fill has a clear + head and tail rather than mirroring around the mid-point. */ +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button .monaco-button:not(.disabled) { + background: conic-gradient(from var(--chat-send-button-anim-angle) at 0% 0%, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 0deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 60%, var(--vscode-input-background)) 90deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 60%, var(--vscode-input-background)) 200deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 360deg); + color: var(--vscode-button-foreground); animation: chat-send-button-spin 18s linear infinite; + transition: box-shadow 250ms ease; } -.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button.disabled)::before { - display: none; -} - -/* Hover/focus: fill the button with a solid color that smoothly cycles through - the gradient palette, rather than spinning the conic gradient. Focus mirrors - hover so keyboard users get the same delightful treatment. */ +/* Hover/focus: soft multi-color glow around the button (no size change). */ .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover) .monaco-button, .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) .monaco-button { - background-color: color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)); - animation: chat-send-button-color-cycle 4.5s ease-in-out infinite; - color: var(--vscode-button-foreground); -} - -.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover)::before, -.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible)::before { - opacity: 0; + box-shadow: + 0 0 4px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 70%, transparent), + 0 0 8px 1px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 55%, transparent); + animation-duration: 6s; } /* Click: outward color pulse on the wrapper. */ @@ -400,52 +378,32 @@ outline: none; } -/* Gradient ring at rest, drawn with the standard mask trick so only the - 1px border area is painted (icon stays transparent over toolbar). */ -.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up)::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - padding: 1px; - background: conic-gradient(from var(--chat-send-button-anim-angle), - var(--vscode-chat-inputWorkingBorderColor1), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor3), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor1)); - -webkit-mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - mask-composite: exclude; - pointer-events: none; - transition: opacity 250ms ease; - z-index: 1; +/* Idle: fill the entire action-label with a slowly rotating conic gradient. + Colors darkened to match hover (60% mixed with input background), with + asymmetric conic stops. */ +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up { + background: conic-gradient(from var(--chat-send-button-anim-angle) at 0% 0%, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 0deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 60%, var(--vscode-input-background)) 90deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 60%, var(--vscode-input-background)) 200deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 360deg) !important; + color: var(--vscode-button-foreground) !important; + border-radius: 5px; animation: chat-send-button-spin 18s linear infinite; + transition: box-shadow 250ms ease; + /* Lift the label above the click pulse `::after` on the action-item parent + so the pulse appears to expand from behind the button, not on top of it. */ + position: relative; + z-index: 1; } -.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.disabled:has(> .action-label.codicon-arrow-up)::before { - display: none; -} - +/* Hover/focus: soft multi-color glow around the button (no size change). */ .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover, .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible { - background-color: color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)); - animation: chat-send-button-color-cycle 4.5s ease-in-out infinite; -} - -.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:hover)::before, -.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible)::before { - opacity: 0; -} - -.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:hover) .codicon-arrow-up, -.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) .codicon-arrow-up { - color: var(--vscode-button-foreground) !important; + box-shadow: + 0 0 4px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 70%, transparent), + 0 0 8px 1px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 55%, transparent); + animation-duration: 6s; } .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after { @@ -465,15 +423,9 @@ } @media (prefers-reduced-motion: reduce) { - /* New-session send button */ - .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button::before, - .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover) .monaco-button, - .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) .monaco-button, + .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button .monaco-button:not(.disabled), + .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up, .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):active)::after, - /* Existing-chat toolbar send button */ - .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up)::before, - .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover, - .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible, .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after { animation: none; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index c93e16cb9d206..567bc815b1bfb 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -7,6 +7,7 @@ import './media/chatWidget.css'; import * as dom from '../../../../base/browser/dom.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { derived } from '../../../../base/common/observable.js'; +import { isWeb } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -24,6 +25,7 @@ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { WorkspacePicker, IWorkspaceSelection } from './sessionWorkspacePicker.js'; +import { ScopedWorkspacePicker } from './scopedWorkspacePicker.js'; import { NewChatInputWidget } from './newChatInput.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -42,7 +44,7 @@ class NewChatWidget extends Disposable { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); - this._workspacePicker = this._register(this.instantiationService.createInstance(WorkspacePicker)); + this._workspacePicker = this._register(this.instantiationService.createInstance(isWeb ? ScopedWorkspacePicker : WorkspacePicker)); const canSendRequest = derived(reader => { const session = this.sessionsManagementService.activeSession.read(reader); diff --git a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts new file mode 100644 index 0000000000000..31f59cfe4af54 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IOutputService } from '../../../../workbench/services/output/common/output.js'; +import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IAgentHostFilterService } from '../../remoteAgentHost/common/agentHostFilter.js'; +import { IWorkspacePickerItem, IWorkspaceSelection, WorkspacePicker } from './sessionWorkspacePicker.js'; + +/** + * A simplified workspace picker that scopes its contents to the host + * currently selected in the agent host filter. It shows: + * + * 1. Recent workspaces for the selected host + * 2. A single "Select Folder..." entry that invokes the host's browse action + * + * Falls back to the Copilot local provider when no host is selected (e.g. on + * desktop, where the host filter UI is not surfaced). + */ +export class ScopedWorkspacePicker extends WorkspacePicker { + + constructor( + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IStorageService storageService: IStorageService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, + @IQuickInputService quickInputService: IQuickInputService, + @IClipboardService clipboardService: IClipboardService, + @IPreferencesService preferencesService: IPreferencesService, + @IOutputService outputService: IOutputService, + @IConfigurationService configurationService: IConfigurationService, + @ICommandService commandService: ICommandService, + @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, + ) { + super( + actionWidgetService, + storageService, + uriIdentityService, + sessionsProvidersService, + sessionsManagementService, + remoteAgentHostService, + quickInputService, + clipboardService, + preferencesService, + outputService, + configurationService, + commandService, + ); + + // When the scoped host changes, if the current selection no longer + // belongs to the selected host, reset it: prefer the most recent + // workspace for the new host, otherwise clear the selection. + this._register(this._agentHostFilterService.onDidChange(() => this._onScopedHostChanged())); + } + + private _onScopedHostChanged(): void { + const scopedProviderId = this._agentHostFilterService.selectedProviderId; + const current = this.selectedProject; + if (current && scopedProviderId !== undefined && current.providerId === scopedProviderId) { + this._onDidChangeSelection.fire(); + return; + } + + const firstRecent = scopedProviderId !== undefined + ? this._getRecentWorkspaces().find(w => w.providerId === scopedProviderId) + : undefined; + if (firstRecent) { + this.setSelectedWorkspace({ providerId: firstRecent.providerId, workspace: firstRecent.workspace }); + return; + } + + this.clearSelection(); + this._onDidSelectWorkspace.fire(undefined); + } + + protected override _buildItems(): IActionListItem[] { + const items: IActionListItem[] = []; + + const scopedProviderId = this._agentHostFilterService.selectedProviderId; + if (scopedProviderId === undefined) { + return []; + } + const provider = this.sessionsProvidersService.getProvider(scopedProviderId); + if (!provider) { + return items; + } + + // 1. Recent workspaces for the scoped provider + const recents = this._getRecentWorkspaces().filter(w => w.providerId === scopedProviderId); + for (const { workspace, providerId } of recents) { + const selection: IWorkspaceSelection = { providerId, workspace }; + items.push({ + kind: ActionListItemKind.Action, + label: workspace.label, + group: { title: '', icon: workspace.icon }, + item: { selection, checked: this._isSelectedWorkspace(selection) || undefined }, + onRemove: () => this._removeRecentWorkspace(selection), + }); + } + + // 2. "Select Folder..." — dispatches the scoped provider's first browse action + const allBrowseActions = this._getAllBrowseActions(); + const browseIndex = allBrowseActions.findIndex(a => a.providerId === scopedProviderId); + if (browseIndex >= 0 && !this._isProviderUnavailable(scopedProviderId)) { + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ + kind: ActionListItemKind.Action, + label: localize('scopedWorkspacePicker.selectFolder', "Select Folder..."), + group: { title: '', icon: Codicon.folderOpened }, + item: { browseActionIndex: browseIndex }, + }); + } + + return items; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 5ff4dcc58eb50..c6efcb7192a2a 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -61,7 +61,7 @@ interface IStoredRecentWorkspace { /** * Item type used in the action list. */ -interface IWorkspacePickerItem { +export interface IWorkspacePickerItem { readonly selection?: IWorkspaceSelection; readonly browseActionIndex?: number; readonly checked?: boolean; @@ -79,9 +79,9 @@ interface IWorkspacePickerItem { */ export class WorkspacePicker extends Disposable { - private readonly _onDidSelectWorkspace = this._register(new Emitter()); + protected readonly _onDidSelectWorkspace = this._register(new Emitter()); readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; - private readonly _onDidChangeSelection = this._register(new Emitter()); + protected readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; private _selectedWorkspace: IWorkspaceSelection | undefined; @@ -98,7 +98,7 @@ export class WorkspacePicker extends Disposable { @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @IStorageService private readonly storageService: IStorageService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, - @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, @IQuickInputService private readonly quickInputService: IQuickInputService, @@ -284,7 +284,7 @@ export class WorkspacePicker extends Disposable { /** * Executes a browse action from a provider, identified by index. */ - private async _executeBrowseAction(actionIndex: number): Promise { + protected async _executeBrowseAction(actionIndex: number): Promise { const allActions = this._getAllBrowseActions(); const action = allActions[actionIndex]; if (!action) { @@ -316,11 +316,11 @@ export class WorkspacePicker extends Disposable { /** * Collects browse actions from all registered providers. */ - private _getAllBrowseActions(): ISessionWorkspaceBrowseAction[] { + protected _getAllBrowseActions(): ISessionWorkspaceBrowseAction[] { return this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions); } - private _buildItems(): IActionListItem[] { + protected _buildItems(): IActionListItem[] { const items: IActionListItem[] = []; // Collect recent workspaces from picker storage across all providers @@ -636,7 +636,7 @@ export class WorkspacePicker extends Disposable { * (disconnected or still connecting). * Returns false for providers without connection status (e.g. local providers). */ - private _isProviderUnavailable(providerId: string): boolean { + protected _isProviderUnavailable(providerId: string): boolean { const provider = this.sessionsProvidersService.getProvider(providerId); if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) { return false; @@ -682,7 +682,7 @@ export class WorkspacePicker extends Disposable { }); } - private _isSelectedWorkspace(selection: IWorkspaceSelection): boolean { + protected _isSelectedWorkspace(selection: IWorkspaceSelection): boolean { if (!this._selectedWorkspace) { return false; } @@ -819,7 +819,7 @@ export class WorkspacePicker extends Disposable { this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); } - private _getRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] { + protected _getRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] { return this._getStoredRecentWorkspaces() .map(stored => { const uri = URI.revive(stored.uri); @@ -841,7 +841,7 @@ export class WorkspacePicker extends Disposable { }); } - private _removeRecentWorkspace(selection: IWorkspaceSelection): void { + protected _removeRecentWorkspace(selection: IWorkspaceSelection): void { const uri = selection.workspace.repositories[0]?.uri; if (!uri) { return; diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index a441cc2871b6e..ce3a51ea72975 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -72,6 +72,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'github.copilot.chat.cli.branchSupport.enabled': true, 'github.copilot.chat.cli.isolationOption.enabled': true, 'github.copilot.chat.cli.mcp.enabled': true, + 'github.copilot.chat.cli.remote.enabled': false, 'github.copilot.chat.githubMcpServer.enabled': true, 'github.copilot.chat.languageContext.typescript.enabled': true, diff --git a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index f62bc4c2fc10e..37ac6d3c5e017 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -105,7 +105,7 @@ The welcome/new-session view (`NewChatInputWidget`) renders three toolbar menus | Menu | Purpose | Examples | |------|---------|----------| -| `Menus.NewSessionConfig` | Session configuration (mode, model) | `ModePicker`, `CloudModelPicker`, local model picker | +| `Menus.NewSessionConfig` | Session configuration (mode, model) | `ModePicker`, `CloudModelPicker`, unified model picker (CLI + Claude) | | `Menus.NewSessionControl` | Session controls (permissions) | `PermissionPicker`, `ClaudePermissionModePicker` | | `Menus.NewSessionRepositoryConfig` | Repository configuration | `IsolationPicker`, `BranchPicker` | diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts index e93478001fe04..810b96766dfed 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -12,8 +12,6 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { Registry } from '../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; import { localize } from '../../../../nls.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', @@ -35,6 +33,13 @@ Registry.as(ConfigurationExtensions.Configuration).regis /** * Registers the {@link CopilotChatSessionsProvider} as a sessions provider. + * + * Coexists with the local agent host provider when `chat.agentHost.enabled` + * is true. The two providers list disjoint sets of sessions: + * - The local agent host filters via the per-session Agent Host SQLite DB + * (database-existence ownership gate in `CopilotAgent.listSessions`). + * - This provider's underlying extension service filters via the per-session + * metadata file's `origin` field, which the local agent host never writes. */ class DefaultSessionsProviderContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.defaultSessionsProvider'; @@ -42,17 +47,9 @@ class DefaultSessionsProviderContribution extends Disposable implements IWorkben constructor( @IInstantiationService instantiationService: IInstantiationService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @IConfigurationService configurationService: IConfigurationService, ) { super(); - // When the local agent host is enabled, skip registering the - // default CopilotChat provider so only the local agent host - // provider is active. - if (configurationService.getValue(AgentHostEnabledSettingId)) { - return; - } - const provider = this._register(instantiationService.createInstance(CopilotChatSessionsProvider)); this._register(sessionsProvidersService.registerProvider(provider)); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 8f907b8c64ecb..629971159fca8 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { coalesce } from '../../../../base/common/arrays.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { IReader, autorun, observableValue } from '../../../../base/common/observable.js'; import { localize2 } from '../../../../nls.js'; import { Action2, registerAction2, MenuId, MenuRegistry, isIMenuItem } from '../../../../platform/actions/common/actions.js'; @@ -110,7 +110,7 @@ registerAction2(class extends Action2 { id: Menus.NewSessionConfig, group: 'navigation', order: 1, - when: IsActiveSessionCopilotChatCLI, + when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatClaudeCode), }], }); } @@ -201,10 +201,6 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, - @ILanguageModelsService languageModelsService: ILanguageModelsService, - @ISessionsManagementService sessionsManagementService: ISessionsManagementService, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @IStorageService storageService: IStorageService, ) { super(); @@ -232,56 +228,8 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor this._register(actionViewItemService.register( Menus.NewSessionConfig, 'sessions.defaultCopilot.localModelPicker', () => { - const currentModel = observableValue('currentModel', undefined); - const delegate: IModelPickerDelegate = { - currentModel, - setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { - currentModel.set(model, undefined); - storageService.store('sessions.localModelPicker.selectedModelId', model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); - const session = sessionsManagementService.activeSession.get(); - if (session) { - const provider = sessionsProvidersService.getProviders().find(p => p.id === session.providerId); - provider?.setModel(session.sessionId, model.identifier); - } - }, - getModels: () => getAvailableModels(languageModelsService, sessionsManagementService), - useGroupedModelPicker: () => true, - showManageModelsAction: () => false, - showUnavailableFeatured: () => false, - showFeatured: () => true, - }; - const pickerOptions: IChatInputPickerOptions = { - hideChevrons: observableValue('hideChevrons', false), - }; - const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; - const modelPicker = instantiationService.createInstance(ModelPickerActionItem, action, delegate, pickerOptions); - - // Initialize with remembered model or first available model - const rememberedModelId = storageService.get('sessions.localModelPicker.selectedModelId', StorageScope.PROFILE); - const initModel = () => { - const models = getAvailableModels(languageModelsService, sessionsManagementService); - modelPicker.setEnabled(models.length > 0); - if (!currentModel.get() && models.length > 0) { - const remembered = rememberedModelId ? models.find(m => m.identifier === rememberedModelId) : undefined; - delegate.setModel(remembered ?? models[0]); - } - }; - initModel(); - - const disposableStore = new DisposableStore(); - disposableStore.add(languageModelsService.onDidChangeLanguageModels(() => initModel())); - - // When the active session changes, push the selected model to the new session - disposableStore.add(autorun(reader => { - const session = sessionsManagementService.activeSession.read(reader); - const model = currentModel.read(reader); - if (session && model) { - const provider = sessionsProvidersService.getProviders().find(p => p.id === session.providerId); - provider?.setModel(session.sessionId, model.identifier); - } - })); - - return new PickerActionViewItem(modelPicker, disposableStore); + const picker = instantiationService.createInstance(SessionModelPicker); + return new PickerActionViewItem(picker); }, )); this._register(actionViewItemService.register( @@ -309,7 +257,105 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor } } -function getAvailableModels( +// -- Model Picker Helpers -- + +/** + * Returns a storage key scoped to the given session type. + */ +export function modelPickerStorageKey(sessionType: string): string { + return `sessions.modelPicker.${sessionType}.selectedModelId`; +} + +/** + * A model picker widget that persists the selected model per session type and + * syncs the selection to the active session's provider. Instantiated via DI, + * consistent with the other picker widgets in this file. + */ +export class SessionModelPicker extends Disposable { + + private readonly _currentModel = observableValue('currentModel', undefined); + private readonly _delegate: IModelPickerDelegate; + private readonly _modelPicker: ModelPickerActionItem; + private _lastSessionType: string | undefined; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + this._delegate = { + currentModel: this._currentModel, + setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { + this._currentModel.set(model, undefined); + const session = this._sessionsManagementService.activeSession.get(); + if (session) { + this._storageService.store(modelPickerStorageKey(session.sessionType), model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); + const provider = this._sessionsProvidersService.getProviders().find(p => p.id === session.providerId); + provider?.setModel(session.sessionId, model.identifier); + } + }, + getModels: () => getAvailableModels(this._languageModelsService, this._sessionsManagementService), + useGroupedModelPicker: () => true, + showManageModelsAction: () => false, + showUnavailableFeatured: () => false, + showFeatured: () => true, + }; + + const pickerOptions: IChatInputPickerOptions = { + hideChevrons: observableValue('hideChevrons', false), + }; + const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; + this._modelPicker = instantiationService.createInstance(ModelPickerActionItem, action, this._delegate, pickerOptions); + + this._initModel(); + this._register(this._languageModelsService.onDidChangeLanguageModels(() => this._initModel())); + + // When the active session changes, re-init (may switch session type). + // _initModel() calls _delegate.setModel() which already forwards to + // the provider, so no additional provider.setModel() call is needed. + this._register(autorun(reader => { + const session = this._sessionsManagementService.activeSession.read(reader); + if (session) { + this._initModel(); + } + })); + } + + private _initModel(): void { + const session = this._sessionsManagementService.activeSession.get(); + const sessionType = session?.sessionType; + + // Reset the current model when switching session types so we load the + // remembered model for the new type instead of carrying over the old one. + if (sessionType !== this._lastSessionType) { + this._currentModel.set(undefined, undefined); + this._lastSessionType = sessionType; + } + + const models = getAvailableModels(this._languageModelsService, this._sessionsManagementService); + this._modelPicker.setEnabled(models.length > 0); + if (!this._currentModel.get() && models.length > 0) { + const rememberedModelId = sessionType ? this._storageService.get(modelPickerStorageKey(sessionType), StorageScope.PROFILE) : undefined; + const remembered = rememberedModelId ? models.find(m => m.identifier === rememberedModelId) : undefined; + this._delegate.setModel(remembered ?? models[0]); + } + } + + render(container: HTMLElement): void { + this._modelPicker.render(container); + } + + override dispose(): void { + this._modelPicker.dispose(); + super.dispose(); + } +} + +export function getAvailableModels( languageModelsService: ILanguageModelsService, sessionsManagementService: ISessionsManagementService, ): ILanguageModelChatMetadataAndIdentifier[] { diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 4797b7946fc98..2fa9bf38d422d 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; -import { raceTimeout } from '../../../../base/common/async.js'; +import { raceCancellationError, raceTimeout } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; @@ -38,8 +38,6 @@ import { IGitService, IGitRepository } from '../../../../workbench/contrib/git/c import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { localize } from '../../../../nls.js'; -import { SessionsGroupModel } from '../../sessions/browser/sessionsGroupModel.js'; -import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IGitHubService } from '../../github/browser/githubService.js'; @@ -1210,8 +1208,20 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions /** Cache of ISession wrappers, keyed by session group ID. */ private readonly _sessionGroupCache = new Map(); - /** Group model tracking which chats belong to which session. */ - private readonly _groupModel: SessionsGroupModel; + /** Cache of chats keyed by raw session ID (resource path without leading slash). */ + private _chatByRawSessionIdCache: Map | undefined; + + /** Cache of derived group IDs keyed by chat ID. */ + private _groupIdByChatIdCache: Map | undefined; + + /** Cache of sorted chat IDs keyed by group ID. */ + private _chatIdsByGroupIdCache: Map | undefined; + + /** + * Emitter fired when the set of chats in a group changes, + * used to update the chats observable in `_chatToSession`. + */ + private readonly _onDidGroupMembershipChange = this._register(new Emitter<{ sessionId: string }>()); private readonly _multiChatEnabled: boolean; private _claudeEnabled: boolean; @@ -1229,14 +1239,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @IInstantiationService private readonly instantiationService: IInstantiationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, - @IStorageService storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, ) { super(); - this._groupModel = this._register(new SessionsGroupModel(storageService)); this._multiChatEnabled = this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; this._claudeEnabled = this.configurationService.getValue(CLAUDE_CODE_ENABLED_SETTING) ?? false; @@ -1292,14 +1300,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return Array.from(this._sessionCache.values()).map(chat => this._chatToSession(chat)); } - const allChats = Array.from(this._sessionCache.values()); + const allChats = Array.from(this._sessionCache.values()).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - // Group chats using the group model + // Group chats using sessionParentId from metadata const seen = new Set(); const sessions: ISession[] = []; for (const chat of allChats) { - const groupId = this._groupModel.getSessionIdForChat(chat.id) ?? chat.id; + const groupId = this._getGroupIdForChat(chat); if (!seen.has(groupId)) { seen.add(groupId); sessions.push(this._chatToSession(chat)); @@ -1394,7 +1402,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } async deleteSession(sessionId: string): Promise { - const chatIds = this._groupModel.getChatIds(sessionId); + const chatIds = this._getChatIdsInGroup(sessionId); // Collect all agent sessions to delete (primary + group members) const allChatIds = new Set([sessionId, ...chatIds]); @@ -1426,7 +1434,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions await this._deleteAgentSessions(agentSessions); - this._groupModel.deleteSession(sessionId); this._sessionGroupCache.delete(sessionId); this._refreshSessionCache(); } @@ -1451,7 +1458,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error('Deleting individual chats is not supported when multi-chat is disabled'); } - const chatIds = this._groupModel.getChatIds(sessionId); + const chatIds = this._getChatIdsInGroup(sessionId); if (chatIds.length <= 1) { // Only one chat — delete the entire session return this.deleteSession(sessionId); @@ -1484,19 +1491,21 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions await this._deleteAgentSessions([agentSession]); } else { - // Untitled chat (not yet committed) — clean up directly - const chat = this._sessionCache.get(this._localIdFromchatId(chatId)); - this._groupModel.removeChat(chatId); + // Untitled chat (not yet committed) - clean up directly + const chat = this._findChatSession(chatId); if (chat) { const key = chat.resource.toString(); this._sessionCache.delete(key); + this._invalidateGroupingCaches(); if (this._currentNewSession?.id === chatId) { this._currentNewSession.dispose(); this._currentNewSession = undefined; } } this._sessionGroupCache.delete(sessionId); - const primaryChatId = this._groupModel.getChatIds(sessionId)[0]; + this._onDidGroupMembershipChange.fire({ sessionId }); + const remainingChatIds = this._getChatIdsInGroup(sessionId); + const primaryChatId = remainingChatIds[0]; const primaryChat = primaryChatId ? this._sessionCache.get(this._localIdFromchatId(primaryChatId)) : undefined; if (primaryChat) { this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(primaryChat)] }); @@ -1547,10 +1556,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions newChatSession.setTitle(localize('new chat', "New Chat")); const key = newChatSession.resource.toString(); this._sessionCache.set(key, newChatSession); - this._groupModel.addChat(sessionId, newChatSession.id); + this._invalidateGroupingCaches(); // Invalidate the session group cache so it rebuilds with the new chat this._sessionGroupCache.delete(sessionId); + this._onDidGroupMembershipChange.fire({ sessionId }); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] }); return this._toChat(newChatSession); @@ -1611,6 +1621,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions attachedContext, }; + // Claude sessions use the ChatSessionItemController API which creates + // real session URIs upfront, bypassing the untitled→commit→swap flow. + if (session instanceof ClaudeCodeNewSession) { + return this._sendFirstChatViaController(session, query, sendOptions); + } + // Open chat widget and set permission level await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); @@ -1623,23 +1639,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } // Load session model with selected options - const modelRef = await this.chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, CancellationToken.None); - if (modelRef) { - const model = modelRef.object; - if (session.selectedModelId) { - const languageModel = this.languageModelsService.lookupLanguageModel(session.selectedModelId); - if (languageModel) { - model.inputModel.setState({ selectedModel: { identifier: session.selectedModelId, metadata: languageModel } }); - } - } - if (session.chatMode) { - model.inputModel.setState({ mode: { id: session.chatMode.id, kind: session.chatMode.kind } }); - } - if (session.selectedOptions.size > 0) { - this.chatSessionsService.updateSessionOptions(session.resource, session.selectedOptions); - } - modelRef.dispose(); - } + await this._applySessionModelState(session.resource, session); // Send request this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, { @@ -1663,6 +1663,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions session.setStatus(SessionStatus.InProgress); const key = session.resource.toString(); this._sessionCache.set(key, session); + this._invalidateGroupingCaches(); const newSession = this._chatToSession(session); this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); @@ -1679,9 +1680,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._currentNewSession = undefined; session.dispose(); - // Register the committed chat in the group model - this._groupModel.addChat(committedChat.id, committedChat.id); - const committedSession = this._chatToSession(committedChat); // Notify listeners that the temp session was replaced by the committed one @@ -1703,6 +1701,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Unexpected error — clean up the temp session entirely this._sessionCache.delete(key); + this._invalidateGroupingCaches(); this._sessionGroupCache.delete(session.id); this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] }); session.dispose(); @@ -1710,23 +1709,162 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } + /** + * Sends the first chat for a Claude session using the controller API. + * + * Unlike the legacy untitled→commit→swap flow, this creates the real + * session URI upfront via {@link IChatSessionsService.createNewChatSessionItem}, + * then sends the request directly to that URI. This avoids the commit + * event race and ensures the session appears under the correct workspace + * immediately. + */ + private async _sendFirstChatViaController( + session: ClaudeCodeNewSession, + query: string, + sendOptions: IChatSendRequestOptions, + ): Promise { + // Create the real session item via the controller's newChatSessionItemHandler. + // This returns a session with a real (non-untitled) URI. + const newItem = await this.chatSessionsService.createNewChatSessionItem( + session.target, + { prompt: query, initialSessionOptions: session.selectedOptions.size > 0 ? session.selectedOptions : undefined }, + CancellationToken.None, + ); + if (!newItem) { + throw new Error('[CopilotChatSessionsProvider] Failed to create Claude session item'); + } + + const realResource = newItem.resource; + + // Open chat session and widget with the real URI + await this.chatSessionsService.getOrCreateChatSession(realResource, CancellationToken.None); + const chatWidget = await this.chatWidgetService.openSession(realResource, ChatViewPaneTarget); + if (!chatWidget) { + throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget'); + } + + const permissionLevel = sendOptions.modeInfo?.permissionLevel; + if (permissionLevel) { + chatWidget.input.setPermissionLevel(permissionLevel); + } + + // Load session model and apply selected options + await this._applySessionModelState(realResource, session); + + // Send request to the real URI — sendRequest skips the + // createNewChatSessionItem block since the URI is not untitled. + this.logService.debug(`[CopilotChatSessionsProvider] Sending first Claude chat to ${realResource.toString()} with options:`, { + userSelectedModelId: sendOptions.userSelectedModelId, + }); + const result = await this.chatService.sendRequest(realResource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[CopilotChatSessionsProvider] sendRequest rejected: ${result.reason}`); + } + + // Add the temp session to the cache immediately so it appears in the sessions list + session.setTitle(newItem.label); + session.setStatus(SessionStatus.InProgress); + const tempKey = session.resource.toString(); + this._sessionCache.set(tempKey, session); + const tempSession = this._chatToSession(session); + this._onDidChangeSessions.fire({ added: [tempSession], removed: [], changed: [] }); + + // Extract response promises for cancellation detection + const responseCreatedPromise = result.kind === 'sent' + ? result.data.responseCreatedPromise + : undefined; + const cts = new CancellationTokenSource(); + // TODO: Understand why we are not awaiting this an only handling the cancellation + responseCreatedPromise?.then(r => { + if (r?.isCanceled) { + cts.cancel(); + } + }); + + try { + // Wait for the agent sessions model to pick up the real session, + // racing against cancellation so we don't timeout when the user + // stops the request before the agent creates a worktree. + const committedChat = await this._waitForSessionInCache(realResource, cts.token); + + // Clean up temp session and replace with the real adapter + this._sessionCache.delete(tempKey); + this._currentNewSession = undefined; + session.dispose(); + + const committedSession = this._chatToSession(committedChat); + this._sessionGroupCache.delete(session.id); + this._onDidReplaceSession.fire({ from: tempSession, to: committedSession }); + + return committedSession; + } catch (error) { + this._currentNewSession = undefined; + + if (error instanceof CancellationError) { + // Keep the temp session visible so the user can review + // whatever content the agent produced before the cancellation. + session.setStatus(SessionStatus.Completed); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [tempSession] }); + return tempSession; + } + + // Unexpected error — clean up the temp session entirely + this._sessionCache.delete(tempKey); + this._sessionGroupCache.delete(session.id); + this._onDidChangeSessions.fire({ added: [], removed: [tempSession], changed: [] }); + session.dispose(); + throw error; + } finally { + cts.dispose(); + } + } + + /** + * Loads the session model for the given resource and applies the selected + * language model, chat mode, and session options from the new session object. + */ + private async _applySessionModelState( + resource: URI, + session: { selectedModelId?: string; chatMode?: IChatMode; selectedOptions: Map }, + ): Promise { + const modelRef = await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + if (!modelRef) { + return; + } + const model = modelRef.object; + if (session.selectedModelId) { + const languageModel = this.languageModelsService.lookupLanguageModel(session.selectedModelId); + if (languageModel) { + model.inputModel.setState({ selectedModel: { identifier: session.selectedModelId, metadata: languageModel } }); + } + } + if (session.chatMode) { + model.inputModel.setState({ mode: { id: session.chatMode.id, kind: session.chatMode.kind } }); + } + if (session.selectedOptions.size > 0) { + this.chatSessionsService.updateSessionOptions(resource, session.selectedOptions); + } + modelRef.dispose(); + } + /** * Sends a subsequent chat for an existing session that already has chats. - * Creates a new {@link CopilotCLISession} from the existing workspace, - * registers it in the group model, and fires a `changed` event (not `added`). + * Creates a new {@link CopilotCLISession} from the existing workspace and + * fires a `changed` event on the grouped session rather than an `added` event. */ private async _sendSubsequentChat(sessionId: string, options: ISendRequestOptions): Promise { // Reuse a chat that was pre-created by addChat(), otherwise create one let newChatSession: CopilotCLISession; - if (this._currentNewSession && this._groupModel.getSessionIdForChat(this._currentNewSession.id) === sessionId) { + if (this._currentNewSession && this._getGroupIdForChat(this._currentNewSession) === sessionId) { newChatSession = this._currentNewSession as CopilotCLISession; } else { newChatSession = this._createNewSessionFrom(sessionId); newChatSession.setTitle(localize('new chat', "New Chat")); const key = newChatSession.resource.toString(); this._sessionCache.set(key, newChatSession); - this._groupModel.addChat(sessionId, newChatSession.id); + this._invalidateGroupingCaches(); this._sessionGroupCache.delete(sessionId); + this._onDidGroupMembershipChange.fire({ sessionId }); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] }); } @@ -1735,7 +1873,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions /** * Sends a request for an existing chat session that is already registered - * in the cache and group model. + * in the cache. */ private async _sendExistingChat(sessionId: string, newChatSession: CopilotCLISession, options: ISendRequestOptions): Promise { // Mark as in progress now that we're sending @@ -1770,34 +1908,18 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const chatWidget = await this.chatWidgetService.openSession(newChatSession.resource, ChatViewPaneTarget); if (!chatWidget) { this._sessionCache.delete(key); - this._groupModel.removeChat(newChatSession.id); + this._invalidateGroupingCaches(); throw new Error('[DefaultCopilotProvider] Failed to open chat widget for subsequent chat'); } // Load session model with selected options - const modelRef = await this.chatService.acquireOrLoadSession(newChatSession.resource, ChatAgentLocation.Chat, CancellationToken.None); - if (modelRef) { - const model = modelRef.object; - if (newChatSession.selectedModelId) { - const languageModel = this.languageModelsService.lookupLanguageModel(newChatSession.selectedModelId); - if (languageModel) { - model.inputModel.setState({ selectedModel: { identifier: newChatSession.selectedModelId, metadata: languageModel } }); - } - } - if (newChatSession.chatMode) { - model.inputModel.setState({ mode: { id: newChatSession.chatMode.id, kind: newChatSession.chatMode.kind } }); - } - if (newChatSession.selectedOptions.size > 0) { - this.chatSessionsService.updateSessionOptions(newChatSession.resource, newChatSession.selectedOptions); - } - modelRef.dispose(); - } + await this._applySessionModelState(newChatSession.resource, newChatSession); // Send request const result = await this.chatService.sendRequest(newChatSession.resource, query, sendOptions); if (result.kind === 'rejected') { this._sessionCache.delete(key); - this._groupModel.removeChat(newChatSession.id); + this._invalidateGroupingCaches(); throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); } @@ -1817,17 +1939,13 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Clean up temp this._sessionCache.delete(key); + this._invalidateGroupingCaches(); this._currentNewSession = undefined; newChatSession.dispose(); - // Atomically replace temp ID with committed ID in the group model - if (this._groupModel.hasGroupForSession(committedChat.id)) { - this._groupModel.deleteSession(committedChat.id); - } - this._groupModel.replaceChat(newChatSession.id, committedChat.id); - - // Invalidate the session group cache so it rebuilds with the new chat + // Invalidate the session group cache so it rebuilds with the committed chat this._sessionGroupCache.delete(sessionId); + this._onDidGroupMembershipChange.fire({ sessionId }); const updatedSession = this._chatToSession(committedChat); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] }); @@ -1847,11 +1965,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Unexpected error — clean up on error, fire changed on the parent session group this._sessionCache.delete(key); - this._groupModel.removeChat(newChatSession.id); + this._invalidateGroupingCaches(); this._sessionGroupCache.delete(sessionId); newChatSession.dispose(); // Find the parent session's primary chat to fire a valid changed event - const parentChatIds = this._groupModel.getChatIds(sessionId); + const parentChatIds = this._getChatIdsInGroup(sessionId); const parentChatId = parentChatIds[0]; const parentChat = parentChatId ? this._sessionCache.get(this._localIdFromchatId(parentChatId)) : undefined; if (parentChat) { @@ -1867,7 +1985,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions */ private _createNewSessionFrom(sessionId: string): CopilotCLISession { // Find the primary chat for this session - const chatIds = this._groupModel.getChatIds(sessionId); + const chatIds = this._getChatIdsInGroup(sessionId); const firstChatId = chatIds[0] ?? sessionId; const chat = this._sessionCache.get(this._localIdFromchatId(firstChatId)); if (!chat) { @@ -1985,7 +2103,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * Only called once during session initialisation (after the commit event), * so the timeout has no performance impact on steady-state operations. */ - private async _waitForSessionInCache(resource: URI): Promise { + private async _waitForSessionInCache(resource: URI, token?: CancellationToken): Promise { const key = resource.toString(); const existing = this._sessionCache.get(key); if (existing instanceof AgentSessionAdapter) { @@ -2005,7 +2123,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // The adapter should appear almost immediately after the commit // event via _refreshSessionCache; use a short safety timeout. - const result = await raceTimeout(sessionPromise, 5_000); + const result = await raceTimeout( + token ? raceCancellationError(sessionPromise, token) : sessionPromise, + 5_000, + ); if (!result) { throw new Error('Timed out waiting for committed session in cache'); } @@ -2079,6 +2200,97 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._refreshSessionCache(); } + private _invalidateGroupingCaches(): void { + this._chatByRawSessionIdCache = undefined; + this._groupIdByChatIdCache = undefined; + this._chatIdsByGroupIdCache = undefined; + } + + private _ensureGroupingCaches(): void { + if (this._chatByRawSessionIdCache && this._groupIdByChatIdCache && this._chatIdsByGroupIdCache) { + return; + } + + const chats = Array.from(this._sessionCache.values()); + const chatByRawSessionId = new Map(); + for (const chat of chats) { + chatByRawSessionId.set(chat.resource.path.slice(1), chat); + } + + const groupIdByChatId = new Map(); + const chatsByGroupId = new Map(); + + const resolveGroupId = (chat: ICopilotChatSession): string => { + const cachedGroupId = groupIdByChatId.get(chat.id); + if (cachedGroupId) { + return cachedGroupId; + } + + const trail: ICopilotChatSession[] = []; + const seen = new Set(); + let current: ICopilotChatSession = chat; + + for (let depth = 0; depth < 100; depth++) { + const currentCachedGroupId = groupIdByChatId.get(current.id); + if (currentCachedGroupId) { + for (const trailChat of trail) { + groupIdByChatId.set(trailChat.id, currentCachedGroupId); + } + return currentCachedGroupId; + } + + if (seen.has(current.id)) { + for (const trailChat of trail) { + groupIdByChatId.set(trailChat.id, current.id); + } + return current.id; + } + + trail.push(current); + seen.add(current.id); + + const parentRawSessionId = this._getDirectParentRawSessionId(current); + if (!parentRawSessionId) { + for (const trailChat of trail) { + groupIdByChatId.set(trailChat.id, current.id); + } + return current.id; + } + + const parentChat = chatByRawSessionId.get(parentRawSessionId); + if (!parentChat) { + const syntheticGroupId = this._getSyntheticGroupId(parentRawSessionId); + for (const trailChat of trail) { + groupIdByChatId.set(trailChat.id, syntheticGroupId); + } + return syntheticGroupId; + } + + current = parentChat; + } + + groupIdByChatId.set(chat.id, chat.id); + return chat.id; + }; + + for (const chat of chats) { + const groupId = resolveGroupId(chat); + const groupChats = chatsByGroupId.get(groupId) ?? []; + groupChats.push(chat); + chatsByGroupId.set(groupId, groupChats); + } + + const chatIdsByGroupId = new Map(); + for (const [groupId, groupChats] of chatsByGroupId) { + groupChats.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + chatIdsByGroupId.set(groupId, groupChats.map(chat => chat.id)); + } + + this._chatByRawSessionIdCache = chatByRawSessionId; + this._groupIdByChatIdCache = groupIdByChatId; + this._chatIdsByGroupIdCache = chatIdsByGroupId; + } + /** * Cleans up a temp session (one that hasn't been committed) from the cache. * Used when delete/archive is invoked on a session that is still pending @@ -2092,6 +2304,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const key = chatSession.resource.toString(); this._sessionCache.delete(key); + this._invalidateGroupingCaches(); this._sessionGroupCache.delete(chatSession.id); if (this._currentNewSession?.id === chatSession.id) { this._currentNewSession = undefined; @@ -2108,6 +2321,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const currentKeys = new Set(); const addedData: ICopilotChatSession[] = []; const changedData: ICopilotChatSession[] = []; + let cacheChanged = false; for (const session of this.agentSessionsService.model.sessions) { if (session.providerType !== AgentSessionProviders.Background @@ -2131,6 +2345,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const adapter = new AgentSessionAdapter(session, this.id, this.gitHubService); this._sessionCache.set(key, adapter); addedData.push(adapter); + cacheChanged = true; } } @@ -2139,9 +2354,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions if (!currentKeys.has(key) && adapter instanceof AgentSessionAdapter) { this._sessionCache.delete(key); removedData.push(adapter); + cacheChanged = true; } } + if (cacheChanged) { + this._invalidateGroupingCaches(); + } + if (addedData.length > 0 || removedData.length > 0 || changedData.length > 0) { if (this._isMultiChatEnabled()) { this._refreshSessionCacheMultiChat(addedData, removedData, changedData); @@ -2160,10 +2380,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions removedData: ICopilotChatSession[], changedData: ICopilotChatSession[], ): void { - // Track session group IDs for removed chats before modifying the group model - const removedGroupIds = new Map(); + // Track session group IDs for removed chats before they leave the cache + const removedGroupIds = new Map(); for (const removed of removedData) { - removedGroupIds.set(removed, this._groupModel.getSessionIdForChat(removed.id)); + removedGroupIds.set(removed, this._getGroupIdForChat(removed)); } // Handle removed chats: if a removed chat belongs to a group with @@ -2172,41 +2392,39 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const trulyRemovedSessions: { chat: ICopilotChatSession; groupId: string }[] = []; const changedSessionIds = new Set(); for (const removed of removedData) { - const sessionId = removedGroupIds.get(removed); - this._groupModel.removeChat(removed.id); - if (sessionId && this._groupModel.getChatIds(sessionId).length > 0) { + const sessionId = removedGroupIds.get(removed)!; + + // Check if the group still has chats after removal + const remainingChatIds = this._getChatIdsInGroup(sessionId); + if (remainingChatIds.length > 0) { // Group still has other chats — invalidate cache and treat as changed this._sessionGroupCache.delete(sessionId); + this._onDidGroupMembershipChange.fire({ sessionId }); if (!changedSessionIds.has(sessionId)) { changedSessionIds.add(sessionId); - const primaryChatId = this._groupModel.getChatIds(sessionId)[0]; - const primaryChat = this._sessionCache.get(this._localIdFromchatId(primaryChatId)); + const primaryChat = this._sessionCache.get(this._localIdFromchatId(remainingChatIds[0])); if (primaryChat) { changedData.push(primaryChat); } } } else { - const groupId = sessionId ?? removed.id; - this._sessionGroupCache.delete(groupId); - trulyRemovedSessions.push({ chat: removed, groupId }); - } - } - - // Seed ungrouped chats into the group model - for (const added of addedData) { - if (!this._groupModel.getSessionIdForChat(added.id)) { - this._groupModel.addChat(added.id, added.id); + this._sessionGroupCache.delete(sessionId); + trulyRemovedSessions.push({ chat: removed, groupId: sessionId }); } } - // Separate truly new sessions from chats added to existing groups + // Separate truly new sessions from chats added to existing groups. + // Grouping is derived from sessionParentId in metadata. const newSessions: ICopilotChatSession[] = []; for (const added of addedData) { - const existingGroupId = this._groupModel.getSessionIdForChat(added.id); - if (existingGroupId && existingGroupId !== added.id) { + const groupId = this._getGroupIdForChat(added); + const groupChatIds = this._getChatIdsInGroup(groupId); + if (groupChatIds.length > 1) { // This chat belongs to an existing session group — treat as changed - if (!changedSessionIds.has(existingGroupId)) { - changedSessionIds.add(existingGroupId); + this._sessionGroupCache.delete(groupId); + this._onDidGroupMembershipChange.fire({ sessionId: groupId }); + if (!changedSessionIds.has(groupId)) { + changedSessionIds.add(groupId); changedData.push(added); } } else { @@ -2218,7 +2436,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const seenChanged = new Set(); const deduplicatedChanged: ICopilotChatSession[] = []; for (const d of changedData) { - const groupId = this._groupModel.getSessionIdForChat(d.id) ?? d.id; + const groupId = this._getGroupIdForChat(d); if (!seenChanged.has(groupId)) { seenChanged.add(groupId); deduplicatedChanged.push(d); @@ -2237,7 +2455,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } private _findChatSession(chatId: string): ICopilotChatSession | undefined { - return this._sessionCache.get(this._localIdFromchatId(chatId)); + const directMatch = this._sessionCache.get(this._localIdFromchatId(chatId)); + if (directMatch) { + return directMatch; + } + + const groupChatIds = this._getChatIdsInGroup(chatId); + const firstChatId = groupChatIds[0]; + return firstChatId ? this._sessionCache.get(this._localIdFromchatId(firstChatId)) : undefined; } private _findAgentSession(chatId: string): IAgentSession | undefined { @@ -2248,6 +2473,47 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this.agentSessionsService.getSession(adapter.resource); } + /** + * Returns the group ID for a given chat. + * Grouping is derived from `sessionParentId` in metadata (for committed sessions) + * or from `PARENT_SESSION_OPTION_ID` in selected options (for uncommitted sessions). + * If the root chat is not loaded, a synthetic provider-scoped group ID is used. + */ + private _getGroupIdForChat(chat: ICopilotChatSession): string { + this._ensureGroupingCaches(); + return this._groupIdByChatIdCache?.get(chat.id) ?? chat.id; + } + + /** + * Returns all chat IDs that belong to the given group, + * ordered by creation time (root session first). + */ + private _getChatIdsInGroup(groupId: string): string[] { + this._ensureGroupingCaches(); + return this._chatIdsByGroupIdCache?.get(groupId) ?? []; + } + + private _getDirectParentRawSessionId(chat: ICopilotChatSession): string | undefined { + const agentSession = this.agentSessionsService.getSession(chat.resource); + const sessionParentId = agentSession?.metadata?.sessionParentId; + if (typeof sessionParentId === 'string' && sessionParentId.length > 0) { + return sessionParentId; + } + + if (isNewSession(chat)) { + const parentOption = chat.selectedOptions.get(PARENT_SESSION_OPTION_ID); + if (parentOption?.id) { + return parentOption.id; + } + } + + return undefined; + } + + private _getSyntheticGroupId(rawSessionId: string): string { + return `${this.id}:group:${rawSessionId}`; + } + private _findSession(sessionId: string): ISession | undefined { return this._sessionGroupCache.get(sessionId); } @@ -2259,8 +2525,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions /** * Wraps a primary {@link ICopilotChatSession} and its sibling chats into an {@link ISession}. - * When multi-chat is enabled, the `chats` observable is derived from the group model - * and updates automatically when the group model fires a change event. + * When multi-chat is enabled, the `chats` observable is derived from `sessionParentId` + * metadata and updates when group membership changes. * When disabled, each session has exactly one chat. */ private _chatToSession(chat: ICopilotChatSession): ISession { @@ -2268,7 +2534,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._chatToSingleChatSession(chat); } - const sessionId = this._groupModel.getSessionIdForChat(chat.id) ?? chat.id; + const sessionId = this._getGroupIdForChat(chat); const cached = this._sessionGroupCache.get(sessionId); if (cached) { @@ -2276,7 +2542,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } // Resolve the main (first) chat in the group — session-level properties come from it - const mainChatIds = this._groupModel.getChatIds(sessionId); + const mainChatIds = this._getChatIdsInGroup(sessionId); const firstChatId = mainChatIds[0]; const primaryChat = firstChatId ? this._sessionCache.get(this._localIdFromchatId(firstChatId)) ?? chat @@ -2284,22 +2550,23 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const chatsObs = observableFromEvent( this, - Event.filter(this._groupModel.onDidChange, e => e.sessionId === sessionId), + Event.filter(this._onDidGroupMembershipChange.event, e => e.sessionId === sessionId), () => { - const chatIds = this._groupModel.getChatIds(sessionId); + const chatIds = this._getChatIdsInGroup(sessionId); if (chatIds.length === 0) { return [this._toChat(chat)]; } - const allChats: ICopilotChatSession[] = Array.from(this._sessionCache.values()); - const chatById = new Map(allChats.map(c => [c.id, c])); - const chatOrder = new Map(chatIds.map((id, index) => [id, index])); - const resolved = chatIds.map(id => chatById.get(id)).filter((c): c is ICopilotChatSession => !!c); + const resolved: ICopilotChatSession[] = []; + for (const id of chatIds) { + const c = this._sessionCache.get(this._localIdFromchatId(id)); + if (c) { + resolved.push(c); + } + } if (resolved.length === 0) { return [this._toChat(chat)]; } - return resolved - .sort((a, b) => (chatOrder.get(a.id) ?? Infinity) - (chatOrder.get(b.id) ?? Infinity)) - .map(c => this._toChat(c)); + return resolved.map(c => this._toChat(c)); }, ); diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index db3ee3ffebb93..3aa430aa68908 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -22,7 +22,7 @@ import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/con import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IChatService, ChatSendResult, IChatSendRequestData } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; @@ -41,6 +41,7 @@ function createMockAgentSession(resource: URI, opts?: { title?: string; archived?: boolean; read?: boolean; + createdAt?: number; metadata?: Record; }): IAgentSession { const providerType = opts?.providerType ?? AgentSessionProviders.Background; @@ -53,7 +54,7 @@ function createMockAgentSession(resource: URI, opts?: { override readonly label = opts?.title ?? 'Test Session'; override readonly status = ChatSessionStatus.Completed; override readonly icon = Codicon.copilot; - override readonly timing = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; + override readonly timing = { created: opts?.createdAt ?? Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; override readonly metadata = opts?.metadata ?? { repositoryPath: '/test/repo' }; override isArchived(): boolean { return archived; } override setArchived(value: boolean): void { archived = value; } @@ -197,7 +198,7 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: () => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem'] }, ): CopilotChatSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -226,6 +227,7 @@ function createProviderForSendTests( setSessionOption: () => true, getSessionOption: () => undefined, onDidChangeOptionGroups: Event.None, + createNewChatSessionItem: opts?.createNewChatSessionItem ?? (async () => undefined), }); instantiationService.stub(IChatService, { acquireOrLoadSession: async () => undefined, @@ -531,6 +533,118 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); + test('groups committed chats using metadata.sessionParentId', () => { + const rootResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/root-session' }); + const child1Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/child-session-1' }); + const child2Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/child-session-2' }); + + model.addSession(createMockAgentSession(rootResource, { title: 'Root', createdAt: 1 })); + model.addSession(createMockAgentSession(child1Resource, { + title: 'Child 1', + createdAt: 2, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' } + })); + model.addSession(createMockAgentSession(child2Resource, { + title: 'Child 2', + createdAt: 3, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' } + })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].chats.get().length, 3); + assert.strictEqual(sessions[0].mainChat.resource.toString(), rootResource.toString()); + }); + + test('orders chats within a grouped session by createdAt', () => { + const rootResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/root-session' }); + const olderChildResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/older-child' }); + const newerChildResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/newer-child' }); + + // Add out of order to ensure grouping order is driven by createdAt rather than insertion order. + model.addSession(createMockAgentSession(newerChildResource, { + title: 'Newer Child', + createdAt: 30, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' } + })); + model.addSession(createMockAgentSession(rootResource, { title: 'Root', createdAt: 10 })); + model.addSession(createMockAgentSession(olderChildResource, { + title: 'Older Child', + createdAt: 20, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' } + })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.deepStrictEqual( + sessions[0].chats.get().map(chat => chat.resource.toString()), + [rootResource.toString(), olderChildResource.toString(), newerChildResource.toString()] + ); + }); + + test('groups child sessions even when the parent/root session is missing', () => { + const orphan1Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/orphan-child-1' }); + const orphan2Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/orphan-child-2' }); + const provider = createProvider(disposables, model); + + provider.getSessions(); // initialize cache + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + model.addSession(createMockAgentSession(orphan1Resource, { + title: 'Orphan Child 1', + createdAt: 1, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' } + })); + model.addSession(createMockAgentSession(orphan2Resource, { + title: 'Orphan Child 2', + createdAt: 2, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' } + })); + + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.deepStrictEqual( + sessions[0].chats.get().map(chat => chat.resource.toString()), + [orphan1Resource.toString(), orphan2Resource.toString()] + ); + assert.deepStrictEqual(changes.map(e => ({ added: e.added.length, changed: e.changed.length })), [ + { added: 1, changed: 0 }, + { added: 0, changed: 1 }, + ]); + }); + + test('groups nested parent chains under the ultimate root', () => { + const middleResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/middle-session' }); + const leafResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/leaf-session' }); + + model.addSession(createMockAgentSession(middleResource, { + title: 'Middle Session', + createdAt: 2, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' } + })); + model.addSession(createMockAgentSession(leafResource, { + title: 'Leaf Session', + createdAt: 3, + metadata: { repositoryPath: '/test/repo', sessionParentId: 'middle-session' } + })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.deepStrictEqual( + sessions[0].chats.get().map(chat => chat.resource.toString()), + [middleResource.toString(), leafResource.toString()] + ); + }); + test('session title comes from primary (first) chat', () => { const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); model.addSession(createMockAgentSession(resource, { title: 'Primary Title' })); @@ -774,12 +888,15 @@ suite('CopilotChatSessionsProvider', () => { // ---- Claude session creation ------- - function makeClaudeInFlightProvider(): { provider: CopilotChatSessionsProvider; cancelRequest: () => void } { + function makeClaudeInFlightProvider(): { provider: CopilotChatSessionsProvider; cancelRequest: () => void; realResource: URI; commitSession: () => void } { let resolveComplete!: () => void; let resolveCreated!: (r: IChatResponseModel) => void; const responseCompletePromise = new Promise(r => { resolveComplete = r; }); const responseCreatedPromise = new Promise(r => { resolveCreated = r; }); + // The real resource that createNewChatSessionItem returns + const realResource = URI.from({ scheme: AgentSessionProviders.Claude, path: `/claude-session-${Date.now()}` }); + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'sent' as const, data: { @@ -787,14 +904,26 @@ suite('CopilotChatSessionsProvider', () => { responseCreatedPromise, agent: new class extends mock() { }(), } as IChatSendRequestData, - }), { claudeEnabled: true }); + }), { + claudeEnabled: true, + createNewChatSessionItem: async (_type, request): Promise => ({ + resource: realResource, + label: request.prompt, + timing: { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }, + }), + }); return { provider, + realResource, cancelRequest: () => { resolveCreated({ isCanceled: true } as unknown as IChatResponseModel); resolveComplete(); }, + commitSession: () => { + // Add the agent session to the model so _waitForSessionInCache resolves + model.addSession(createMockAgentSession(realResource, { providerType: AgentSessionProviders.Claude })); + }, }; } @@ -810,7 +939,7 @@ suite('CopilotChatSessionsProvider', () => { } test('createNewSession with Claude type creates a session', async () => { - const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const { provider, commitSession } = makeClaudeInFlightProvider(); const workspace = URI.file('/test/project'); const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); @@ -819,13 +948,12 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(session.sessionType, ClaudeCodeSessionType.id); assert.strictEqual(session.status.get(), SessionStatus.Untitled); - // Send and clean up so the session enters the cache and can be disposed + // Send and commit so the session enters the cache and can be disposed const added = waitForSessionAdded(provider); const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); await added; - cancelRequest(); + commitSession(); await assert.doesNotReject(sendPromise); - await provider.deleteSession(session.sessionId); }); test('archiveSession archives a Claude temp session', async () => { @@ -869,6 +997,66 @@ suite('CopilotChatSessionsProvider', () => { await provider.deleteSession(session.sessionId); }); + // ---- Claude controller-based send flow ------- + + test('sendAndCreateChat replaces temp session with committed session on success', async () => { + const { provider, commitSession } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + const replacements: { from: unknown; to: unknown }[] = []; + disposables.add(provider.onDidReplaceSession(e => replacements.push(e))); + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'hello world' }); + await added; + + assert.strictEqual(provider.getSessions().length, 1, 'temp session should appear while in-flight'); + + // Simulate the agent session appearing in the model + commitSession(); + await sendPromise; + + // The temp session should have been replaced by the committed one + assert.ok(replacements.length > 0, 'onDidReplaceSessions should have fired'); + }); + + test('sendAndCreateChat uses the query as the temp session title', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'fix the login bug' }); + await added; + + const sessions = provider.getSessions(); + assert.strictEqual(sessions[0].title.get(), 'fix the login bug'); + + cancelRequest(); + await assert.doesNotReject(sendPromise); + await provider.deleteSession(session.sessionId); + }); + + test('sendAndCreateChat keeps temp session on cancellation', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + await added; + + // Cancel before the agent session appears + cancelRequest(); + await sendPromise; + + assert.strictEqual(provider.getSessions().length, 1, 'session should remain after cancellation'); + assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed, 'should be marked completed'); + + await provider.deleteSession(session.sessionId); + }); + // ---- Rename ------- test('renameChat delegates to claude rename command', async () => { diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts new file mode 100644 index 0000000000000..8fa751dae241c --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { CLAUDE_CODE_SESSION_TYPE, COPILOT_CLI_SESSION_TYPE } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { getAvailableModels, modelPickerStorageKey, SessionModelPicker } from '../../browser/copilotChatSessionsActions.js'; + +function makeModel(id: string, sessionType: string): ILanguageModelChatMetadataAndIdentifier { + return { + identifier: id, + metadata: { targetChatSessionType: sessionType } as ILanguageModelChatMetadata, + }; +} + +function stubServices( + disposables: DisposableStore, + opts?: { + models?: ILanguageModelChatMetadataAndIdentifier[]; + activeSession?: Partial; + storedEntries?: Map; + setModelSpy?: (sessionId: string, modelId: string) => void; + }, +): { instantiationService: TestInstantiationService; storage: Map; activeSession: ReturnType> } { + const instantiationService = disposables.add(new TestInstantiationService()); + const models = opts?.models ?? []; + const storage = opts?.storedEntries ?? new Map(); + + const activeSession = opts?.activeSession + ? observableValue('activeSession', opts.activeSession as IActiveSession) + : observableValue('activeSession', undefined); + + const setModelSpy = opts?.setModelSpy ?? (() => { }); + + instantiationService.stub(ILanguageModelsService, { + onDidChangeLanguageModels: Event.None, + getLanguageModelIds: () => models.map(m => m.identifier), + lookupLanguageModel: (id: string) => models.find(m => m.identifier === id)?.metadata, + } as Partial); + + instantiationService.stub(IStorageService, { + get: (key: string, _scope: StorageScope) => storage.get(key), + store: (key: string, value: string, _scope: StorageScope, _target: StorageTarget) => { storage.set(key, value); }, + } as Partial); + + const provider: Partial = { + id: 'default-copilot', + setModel: setModelSpy, + }; + + instantiationService.stub(ISessionsManagementService, { + activeSession, + } as unknown as ISessionsManagementService); + + instantiationService.stub(ISessionsProvidersService, { + onDidChangeProviders: Event.None, + getProviders: () => [provider as ISessionsProvider], + } as Partial); + + // Stub IInstantiationService so SessionModelPicker can call createInstance for ModelPickerActionItem + instantiationService.stub(IInstantiationService, instantiationService); + + return { instantiationService, storage, activeSession }; +} + +suite('modelPickerStorageKey', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('produces per-session-type keys', () => { + assert.strictEqual(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE), `sessions.modelPicker.${COPILOT_CLI_SESSION_TYPE}.selectedModelId`); + assert.strictEqual(modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE), `sessions.modelPicker.${CLAUDE_CODE_SESSION_TYPE}.selectedModelId`); + }); +}); + +suite('getAvailableModels', () => { + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns empty when no active session', () => { + const models = [makeModel('model-1', COPILOT_CLI_SESSION_TYPE)]; + const { instantiationService } = stubServices(disposables, { models }); + const languageModelsService = instantiationService.get(ILanguageModelsService); + const sessionsManagementService = instantiationService.get(ISessionsManagementService); + const result = getAvailableModels(languageModelsService, sessionsManagementService); + assert.deepStrictEqual(result, []); + }); + + test('filters models by session type', () => { + const models = [ + makeModel('cli-model', COPILOT_CLI_SESSION_TYPE), + makeModel('cloud-model', 'copilot-cloud'), + makeModel('claude-model', CLAUDE_CODE_SESSION_TYPE), + ]; + const { instantiationService } = stubServices(disposables, { + models, + activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE }, + }); + const languageModelsService = instantiationService.get(ILanguageModelsService); + const sessionsManagementService = instantiationService.get(ISessionsManagementService); + const result = getAvailableModels(languageModelsService, sessionsManagementService); + assert.deepStrictEqual(result, [models[2]]); + }); +}); + +suite('SessionModelPicker', () => { + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('stores selected model under session-type-scoped key', () => { + const models = [makeModel('model-1', CLAUDE_CODE_SESSION_TYPE)]; + const { instantiationService, storage } = stubServices(disposables, { + models, + activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE }, + }); + // Creating the picker triggers initModel which calls setModel for the first available model + disposables.add(instantiationService.createInstance(SessionModelPicker)); + assert.strictEqual(storage.get(modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE)), 'model-1'); + assert.strictEqual(storage.has(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE)), false); + }); + + test('calls provider.setModel on init', () => { + const calls: { sessionId: string; modelId: string }[] = []; + const models = [makeModel('model-1', CLAUDE_CODE_SESSION_TYPE)]; + const { instantiationService } = stubServices(disposables, { + models, + activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE }, + setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }), + }); + disposables.add(instantiationService.createInstance(SessionModelPicker)); + assert.ok(calls.some(c => c.sessionId === 'sess-1' && c.modelId === 'model-1')); + }); + + test('remembers model per session type from storage', () => { + const models = [makeModel('model-a', CLAUDE_CODE_SESSION_TYPE), makeModel('model-b', CLAUDE_CODE_SESSION_TYPE)]; + const storedEntries = new Map([[modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE), 'model-b']]); + const calls: { sessionId: string; modelId: string }[] = []; + const { instantiationService } = stubServices(disposables, { + models, + activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE }, + storedEntries, + setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }), + }); + disposables.add(instantiationService.createInstance(SessionModelPicker)); + // Should pick model-b (remembered) instead of model-a (first) + assert.ok(calls.some(c => c.modelId === 'model-b')); + }); + + test('does not throw when no active session', () => { + const { instantiationService } = stubServices(disposables); + assert.doesNotThrow(() => disposables.add(instantiationService.createInstance(SessionModelPicker))); + }); + + test('different session types use independent storage keys', () => { + const cliModels = [makeModel('cli-m', COPILOT_CLI_SESSION_TYPE)]; + const claudeModels = [makeModel('claude-m', CLAUDE_CODE_SESSION_TYPE)]; + const allModels = [...cliModels, ...claudeModels]; + + const { instantiationService, storage, activeSession } = stubServices(disposables, { + models: allModels, + activeSession: { providerId: 'default-copilot', sessionId: 's1', sessionType: COPILOT_CLI_SESSION_TYPE }, + }); + disposables.add(instantiationService.createInstance(SessionModelPicker)); + assert.strictEqual(storage.get(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE)), 'cli-m'); + + // Switch session type + activeSession.set({ providerId: 'default-copilot', sessionId: 's2', sessionType: CLAUDE_CODE_SESSION_TYPE } as IActiveSession, undefined); + + assert.strictEqual(storage.get(modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE)), 'claude-m'); + // CLI key should still be intact + assert.strictEqual(storage.get(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE)), 'cli-m'); + }); +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFilterService.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFilterService.ts new file mode 100644 index 0000000000000..3a1590a388d43 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFilterService.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { isWeb } from '../../../../base/common/platform.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { isAgentHostProvider, IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { AgentHostFilterConnectionStatus, IAgentHostFilterEntry, IAgentHostFilterService } from '../common/agentHostFilter.js'; + +const STORAGE_KEY = 'sessions.agentHostFilter.selectedProviderId'; + +function mapStatus(s: RemoteAgentHostConnectionStatus): AgentHostFilterConnectionStatus { + switch (s) { + case RemoteAgentHostConnectionStatus.Connected: return AgentHostFilterConnectionStatus.Connected; + case RemoteAgentHostConnectionStatus.Connecting: return AgentHostFilterConnectionStatus.Connecting; + case RemoteAgentHostConnectionStatus.Disconnected: + default: return AgentHostFilterConnectionStatus.Disconnected; + } +} + +/** + * Returns `true` if the given provider is a remote agent host provider that + * exposes a connection status and a remote address — i.e. the providers that + * the host filter combo is responsible for surfacing. + */ +function isRemoteAgentHostProvider(provider: unknown): provider is IAgentHostSessionsProvider & { readonly remoteAddress: string } { + if (!provider || typeof provider !== 'object' || !('id' in provider)) { + return false; + } + const p = provider as IAgentHostSessionsProvider; + return isAgentHostProvider(p) && p.connectionStatus !== undefined && typeof p.remoteAddress === 'string'; +} + +export class AgentHostFilterService extends Disposable implements IAgentHostFilterService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _selectedProviderId: string | undefined; + private _hosts: readonly IAgentHostFilterEntry[] = []; + + /** + * Subscriptions to the `connectionStatus` observable of every currently + * registered remote provider. Rebuilt whenever the set of providers + * changes so we always observe the live set. + */ + private readonly _providerWatchers = this._register(new DisposableStore()); + + constructor( + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + this._selectedProviderId = this._storageService.get(STORAGE_KEY, StorageScope.PROFILE, undefined); + + this._rewatchProviders(); + this._register(this._sessionsProvidersService.onDidChangeProviders(() => this._rewatchProviders())); + } + + get selectedProviderId(): string | undefined { + return this._selectedProviderId; + } + + get hosts(): readonly IAgentHostFilterEntry[] { + return this._hosts; + } + + setSelectedProviderId(providerId: string): void { + if (!this._hosts.some(h => h.providerId === providerId)) { + return; + } + if (providerId === this._selectedProviderId) { + return; + } + this._selectedProviderId = providerId; + this._persist(); + this._onDidChange.fire(); + } + + reconnect(providerId: string): void { + const provider = this._sessionsProvidersService.getProvider(providerId); + if (provider && isAgentHostProvider(provider) && provider.connect) { + provider.connect().catch(() => { /* errors are surfaced by the provider */ }); + return; + } + const host = this._hosts.find(h => h.providerId === providerId); + if (!host) { + return; + } + this._remoteAgentHostService.reconnect(host.address); + } + + disconnect(providerId: string): void { + const provider = this._sessionsProvidersService.getProvider(providerId); + if (provider && isAgentHostProvider(provider) && provider.disconnect) { + provider.disconnect().catch(() => { /* errors are surfaced by the provider */ }); + } + } + + private _validate(providerId: string | undefined): string | undefined { + if (providerId !== undefined && this._hosts.some(h => h.providerId === providerId)) { + return providerId; + } + return this._hosts.length > 0 ? this._hosts[0].providerId : undefined; + } + + /** + * Subscribe to the current set of remote providers so that host list + * updates (registration/unregistration and status changes) are surfaced + * via {@link onDidChange}. One `autorun` reads every provider's + * `connectionStatus` observable and recomputes the host list. + */ + private _rewatchProviders(): void { + this._providerWatchers.clear(); + + const providers = this._sessionsProvidersService.getProviders().filter(isRemoteAgentHostProvider); + + this._providerWatchers.add(autorun(reader => { + const hosts: IAgentHostFilterEntry[] = providers.map(provider => ({ + providerId: provider.id, + label: provider.label, + address: provider.remoteAddress, + status: mapStatus(provider.connectionStatus!.read(reader)), + })).sort((a, b) => a.label.localeCompare(b.label)); + + this._applyHosts(hosts); + })); + } + + private _applyHosts(hosts: readonly IAgentHostFilterEntry[]): void { + const changed = hosts.length !== this._hosts.length + || hosts.some((h, i) => h.providerId !== this._hosts[i].providerId + || h.label !== this._hosts[i].label + || h.address !== this._hosts[i].address + || h.status !== this._hosts[i].status); + + this._hosts = hosts; + + const validated = isWeb ? this._validate(this._selectedProviderId) : undefined; + const selectionChanged = validated !== this._selectedProviderId; + if (selectionChanged) { + this._selectedProviderId = validated; + this._persist(); + } + + if (changed || selectionChanged) { + this._onDidChange.fire(); + } + } + + private _persist(): void { + if (this._selectedProviderId === undefined) { + this._storageService.remove(STORAGE_KEY, StorageScope.PROFILE); + } else { + this._storageService.store(STORAGE_KEY, this._selectedProviderId, StorageScope.PROFILE, StorageTarget.USER); + } + } +} + +registerSingleton(IAgentHostFilterService, AgentHostFilterService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts new file mode 100644 index 0000000000000..45a8134f90a12 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize2 } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Menus } from '../../../browser/menus.js'; +import { IAgentHostFilterService } from '../common/agentHostFilter.js'; +import { HostFilterActionViewItem } from './hostFilterActionViewItem.js'; + +/** + * Context key that is `true` when at least one remote agent host is known + * (configured or connected). Controls the visibility of the host filter + * dropdown in the titlebar. + */ +const HasAgentHostsContext = new RawContextKey('sessions.hasAgentHosts', false); + +const PICK_HOST_FILTER_ID = 'sessions.agentHostFilter.pick'; + +/** + * Action that backs the host filter dropdown in the titlebar. Selection + * is actually handled by {@link HostFilterActionViewItem}, so the action's + * `run` is a no-op. Gated on `isWeb` via its menu `when` clause so the + * combo only shows up in the web build. + */ +registerAction2(class PickAgentHostFilterAction extends Action2 { + constructor() { + super({ + id: PICK_HOST_FILTER_ID, + title: localize2('agentHostFilter.pick', "Select Agent Host"), + f1: false, + menu: [{ + id: Menus.TitleBarLeftLayout, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + IsWebContext, + IsAuxiliaryWindowContext.toNegated(), + HasAgentHostsContext, + ), + }], + }); + } + + override async run(_accessor: ServicesAccessor): Promise { + // Handled by HostFilterActionViewItem + } +}); + +class AgentHostFilterContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.agentHostFilter'; + + private readonly _hasAgentHostsContext: IContextKey; + + constructor( + @IAgentHostFilterService filterService: IAgentHostFilterService, + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + this._hasAgentHostsContext = HasAgentHostsContext.bindTo(contextKeyService); + this._update(filterService); + + this._register(filterService.onDidChange(() => this._update(filterService))); + + this._register(actionViewItemService.register( + Menus.TitleBarLeftLayout, + PICK_HOST_FILTER_ID, + (action, _options, instaService) => instaService.createInstance(HostFilterActionViewItem, action), + filterService.onDidChange, + )); + } + + private _update(filterService: IAgentHostFilterService): void { + this._hasAgentHostsContext.set(filterService.hosts.length > 0); + } +} + +registerWorkbenchContribution2( + AgentHostFilterContribution.ID, + AgentHostFilterContribution, + WorkbenchPhase.AfterRestored, +); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts new file mode 100644 index 0000000000000..a618abe464697 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts @@ -0,0 +1,273 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/hostFilter.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Action, IAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { AgentHostFilterConnectionStatus, IAgentHostFilterEntry, IAgentHostFilterService } from '../common/agentHostFilter.js'; + +/** + * Compound titlebar widget shown next to the toggle sidebar button in the + * Agent Sessions window. It fills the remaining left-toolbar width (which + * matches the sidebar width) and renders two controls side-by-side: + * + * - Left: a dropdown pill indicating the currently selected host; clicking + * opens a context menu to pick a different host. When only a single + * host is available the pill renders as a static label (no chevron, + * no click target). + * - Right: a connection-status button for the selected host: + * • Connected → green `debug-connected` (non-interactive) + * • Connecting → `debug-connected` pulsing, non-interactive + * • Connected → clickable `debug-disconnect`; click tears down the connection\n * • Connecting → pulsing `debug-disconnect`; click cancels the attempt\n * • Disconnected → clickable `debug-connected`; click triggers a fresh connect + */ +export class HostFilterActionViewItem extends BaseActionViewItem { + + private _dropdownElement: HTMLElement | undefined; + private _labelElement: HTMLElement | undefined; + private _chevronElement: HTMLElement | undefined; + private _connectElement: HTMLElement | undefined; + + private readonly _dropdownHover = this._register(new MutableDisposable()); + private readonly _connectHover = this._register(new MutableDisposable()); + + constructor( + action: IAction, + @IAgentHostFilterService private readonly _filterService: IAgentHostFilterService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IHoverService private readonly _hoverService: IHoverService, + ) { + super(undefined, action); + + this._register(this._filterService.onDidChange(() => this._update())); + } + + override render(container: HTMLElement): void { + super.render(container); + + if (!this.element) { + return; + } + + this.element.classList.add('agent-host-filter-combo'); + + // --- Dropdown pill (left) ----------------------------------------------- + this._dropdownElement = dom.append(this.element, dom.$('div.agent-host-filter-dropdown')); + + const iconEl = dom.append(this._dropdownElement, dom.$('span.agent-host-filter-icon')); + iconEl.append(...renderLabelWithIcons(`$(${Codicon.remote.id})`)); + + this._labelElement = dom.append(this._dropdownElement, dom.$('span.agent-host-filter-label')); + + this._chevronElement = dom.append(this._dropdownElement, dom.$('span.agent-host-filter-chevron')); + this._chevronElement.append(...renderLabelWithIcons(`$(${Codicon.chevronDown.id})`)); + + this._register(dom.addDisposableListener(this._dropdownElement, dom.EventType.CLICK, e => { + if (!this._isInteractive()) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this._showMenu(e); + })); + this._register(dom.addDisposableListener(this._dropdownElement, dom.EventType.KEY_DOWN, e => { + if (!this._isInteractive()) { + return; + } + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + e.preventDefault(); + e.stopPropagation(); + this._showMenu(e); + } + })); + + // --- Connection button (right) ------------------------------------------ + this._connectElement = dom.append(this.element, dom.$('div.agent-host-filter-connect')); + + this._register(dom.addDisposableListener(this._connectElement, dom.EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + this._onConnectClick(); + })); + this._register(dom.addDisposableListener(this._connectElement, dom.EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + e.preventDefault(); + e.stopPropagation(); + this._onConnectClick(); + } + })); + + this._update(); + } + + private _isInteractive(): boolean { + return this._filterService.hosts.length > 1; + } + + private _update(): void { + if (!this.element || !this._dropdownElement || !this._labelElement || !this._chevronElement || !this._connectElement) { + return; + } + + const hosts = this._filterService.hosts; + const selectedId = this._filterService.selectedProviderId; + const selected = selectedId === undefined + ? undefined + : hosts.find(h => h.providerId === selectedId); + + const interactive = hosts.length > 1; + + // Dropdown label + aria + const text = selected ? selected.label : localize('agentHostFilter.none', "No Host"); + this._labelElement.textContent = text; + + this.element.classList.toggle('single-host', !interactive); + + if (interactive) { + this._dropdownElement.tabIndex = 0; + this._dropdownElement.role = 'button'; + this._dropdownElement.setAttribute('aria-haspopup', 'menu'); + this._dropdownElement.setAttribute('aria-label', selected + ? localize('agentHostFilter.aria.selected', "Sessions scoped to host {0}. Click to change host.", selected.label) + : localize('agentHostFilter.aria.none', "No agent host selected.")); + this._dropdownHover.value = this._hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + this._dropdownElement, + () => localize('agentHostFilter.hover', "Change the host the sessions list is scoped to"), + ); + } else { + this._dropdownElement.removeAttribute('tabindex'); + this._dropdownElement.removeAttribute('role'); + this._dropdownElement.removeAttribute('aria-haspopup'); + this._dropdownElement.setAttribute('aria-label', selected + ? localize('agentHostFilter.aria.singleSelected', "Sessions scoped to host {0}", selected.label) + : localize('agentHostFilter.aria.none', "No agent host selected.")); + this._dropdownHover.clear(); + } + + this._updateConnectButton(selected); + } + + private _updateConnectButton(selected: IAgentHostFilterEntry | undefined): void { + if (!this._connectElement) { + return; + } + + dom.clearNode(this._connectElement); + this._connectElement.classList.remove('connected', 'connecting', 'disconnected', 'hidden'); + this._connectHover.clear(); + + if (!selected) { + this._connectElement.classList.add('hidden'); + this._connectElement.removeAttribute('role'); + this._connectElement.removeAttribute('tabindex'); + return; + } + + // Always render as a button; clicking forces a fresh connect attempt + // regardless of current state (the platform service tears down any + // existing connection before reconnecting). + this._connectElement.setAttribute('role', 'button'); + this._connectElement.tabIndex = 0; + + let iconId: string; + let hoverText: string; + switch (selected.status) { + case AgentHostFilterConnectionStatus.Connected: + iconId = Codicon.debugConnected.id; + this._connectElement.classList.add('connected'); + hoverText = localize('agentHostFilter.status.connected', "Connected to {0}. Click to disconnect.", selected.label); + break; + case AgentHostFilterConnectionStatus.Connecting: + iconId = Codicon.debugConnected.id; + this._connectElement.classList.add('connecting'); + hoverText = localize('agentHostFilter.status.connecting', "Connecting to {0}… Click to cancel.", selected.label); + break; + case AgentHostFilterConnectionStatus.Disconnected: + default: + iconId = Codicon.debugDisconnect.id; + this._connectElement.classList.add('disconnected'); + hoverText = localize('agentHostFilter.status.disconnected', "Disconnected from {0}. Click to connect.", selected.label); + break; + } + this._connectElement.append(...renderLabelWithIcons(`$(${iconId})`)); + this._connectElement.setAttribute('aria-label', hoverText); + + const connectHoverDelegate = getDefaultHoverDelegate('element'); + this._connectHover.value = this._hoverService.setupManagedHover( + connectHoverDelegate, + this._connectElement, + () => hoverText, + ); + } + + private _onConnectClick(): void { + const selectedId = this._filterService.selectedProviderId; + if (selectedId === undefined) { + return; + } + const selected = this._filterService.hosts.find(h => h.providerId === selectedId); + if (!selected) { + return; + } + if (selected.status === AgentHostFilterConnectionStatus.Disconnected) { + this._filterService.reconnect(selectedId); + } else { + // Connected or Connecting — clicking tears down the current + // connection / cancels the in-flight attempt. + this._filterService.disconnect(selectedId); + } + } + + private _showMenu(e: MouseEvent | KeyboardEvent): void { + if (!this._dropdownElement) { + return; + } + + const hosts = this._filterService.hosts; + if (hosts.length <= 1) { + return; + } + const selectedId = this._filterService.selectedProviderId; + + const actions: IAction[] = []; + for (const host of hosts) { + const label = host.status === AgentHostFilterConnectionStatus.Connected + ? host.label + : host.status === AgentHostFilterConnectionStatus.Connecting + ? localize('agentHostFilter.hostConnecting', "{0} (connecting…)", host.label) + : localize('agentHostFilter.hostDisconnected', "{0} (disconnected)", host.label); + actions.push(new Action( + `agentHostFilter.host.${host.providerId}`, + label, + selectedId === host.providerId ? 'codicon codicon-check' : undefined, + true, + async () => this._filterService.setSelectedProviderId(host.providerId), + )); + } + + const anchor = dom.isMouseEvent(e) + ? new StandardMouseEvent(dom.getWindow(this._dropdownElement), e) + : this._dropdownElement; + + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions, + domForShadowRoot: this._dropdownElement, + }); + } +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css new file mode 100644 index 0000000000000..fb19b063af93f --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Compound widget (dropdown pill + connect button). Expands to fill the + * space available in the titlebar's left toolbar after the sidebar toggle, + * so the pill + connect button appear centered in the remaining width. */ +.agent-host-filter-combo { + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 auto; + min-width: 0; + gap: 4px; + height: 22px; + padding-right: 4px; +} + +/* --- Dropdown pill ------------------------------------------------------ */ + +.agent-host-filter-combo .agent-host-filter-dropdown { + display: flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 6px; + margin: 0 2px; + cursor: pointer; + color: var(--vscode-titleBar-activeForeground); + border-radius: 4px; + font-size: 12px; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; + flex: 0 1 auto; + min-width: 0; + max-width: 260px; +} + +.agent-host-filter-combo .agent-host-filter-dropdown:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.agent-host-filter-combo .agent-host-filter-dropdown:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.agent-host-filter-combo .agent-host-filter-icon, +.agent-host-filter-combo .agent-host-filter-chevron { + display: flex; + align-items: center; + flex: 0 0 auto; +} + +.agent-host-filter-combo .agent-host-filter-icon .codicon { + font-size: 14px; +} + +.agent-host-filter-combo .agent-host-filter-chevron .codicon { + font-size: 12px; + opacity: 0.8; +} + +.agent-host-filter-combo .agent-host-filter-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + min-width: 0; +} + +/* --- Single-host mode: static label, no dropdown affordance ------------- */ + +.agent-host-filter-combo.single-host .agent-host-filter-dropdown { + cursor: default; +} + +.agent-host-filter-combo.single-host .agent-host-filter-dropdown:hover { + background-color: transparent; +} + +.agent-host-filter-combo.single-host .agent-host-filter-chevron { + display: none; +} + +/* --- Connection button (right edge) ------------------------------------- */ + +.agent-host-filter-combo .agent-host-filter-connect { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + margin-left: auto; + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + touch-action: manipulation; + -webkit-user-select: none; + user-select: none; +} + +.agent-host-filter-combo .agent-host-filter-connect.hidden { + display: none; +} + +.agent-host-filter-combo .agent-host-filter-connect .codicon { + font-size: 14px; +} + +.agent-host-filter-combo .agent-host-filter-connect.connected .codicon { + color: var(--vscode-testing-iconPassed, var(--vscode-debugIcon-startForeground, #388a34)) !important; +} + +.agent-host-filter-combo .agent-host-filter-connect.connecting .codicon { + color: var(--vscode-debugIcon-startForeground, #388a34); + animation: agent-host-filter-pulse 1.2s ease-in-out infinite; +} + +.agent-host-filter-combo .agent-host-filter-connect.disconnected .codicon { + color: var(--vscode-descriptionForeground); +} + +.agent-host-filter-combo .agent-host-filter-connect:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.agent-host-filter-combo .agent-host-filter-connect:hover .codicon { + color: var(--vscode-foreground); +} + +.agent-host-filter-combo .agent-host-filter-connect:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +@keyframes agent-host-filter-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index a487bf9d254a2..64088fd955fba 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -16,7 +16,7 @@ import { localize } from '../../../../nls.js'; import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -94,6 +94,8 @@ export interface IRemoteAgentHostSessionsProviderConfig { readonly name: string; /** Optional hook to establish a connection on demand (e.g. tunnel relay). */ readonly connectOnDemand?: () => Promise; + /** Optional hook to tear down the active connection on demand (e.g. tunnel relay). */ + readonly disconnectOnDemand?: () => Promise; } /** @@ -145,6 +147,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _connectionListeners = this._register(new DisposableStore()); private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise) | undefined; + private readonly _disconnectOnDemand: (() => Promise) | undefined; /** Storage key used for persisting {@link _sessionCache} snapshots. */ private readonly _storageKey: string; /** @@ -179,11 +182,13 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @ILanguageModelsService languageModelsService: ILanguageModelsService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand; + this._disconnectOnDemand = config.disconnectOnDemand; const displayName = config.name || config.address; this.id = `agenthost-${this._connectionAuthority}`; @@ -278,6 +283,35 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid // -- Connection lifecycle ------------------------------------------------ + /** + * Establish (or re-establish) the connection for this host on demand. + * Tunnel-backed providers use their relay hook; other providers fall + * back to the generic remote agent host reconnect path. + */ + async connect(): Promise { + if (this._connectOnDemand) { + await this._connectOnDemand(); + return; + } + this._remoteAgentHostService.reconnect(this.remoteAddress); + } + + /** + * Tear down the active connection for this host. Tunnel-backed providers + * use their relay hook; other providers fall back to the generic remote + * agent host disconnect path. Cached sessions are hidden from the UI so + * the sessions list reflects the disconnected state; the persisted cache + * is retained so sessions can be restored on reconnect. + */ + async disconnect(): Promise { + this.unpublishCachedSessions(); + if (this._disconnectOnDemand) { + await this._disconnectOnDemand(); + return; + } + await this._remoteAgentHostService.removeRemoteAgentHost(this.remoteAddress); + } + /** Update the connection status for this provider. */ setConnectionStatus(status: RemoteAgentHostConnectionStatus): void { this._connectionStatus.set(status, undefined); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 4b7379bd1227d..5647e7d29c82b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -184,6 +184,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc address, name, connectOnDemand: () => this._connectTunnel(address, { userInitiated: true }), + disconnectOnDemand: () => this._disconnectTunnel(address), }, ); // Surface as "Connecting" until the first silent status check or an @@ -328,6 +329,21 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc return promise; } + /** + * Tear down the active tunnel relay for {@link address} and cancel any + * pending auto-reconnect. The cached tunnel entry is kept so the user + * can re-connect later; only the live WebSocket is closed. + */ + private async _disconnectTunnel(address: string): Promise { + this._cancelReconnect(address); + this._resetReconnectState(address); + // Mark as explicitly disconnected so `_handleConnectionChanges` does + // not treat the impending Connected→(removed) transition as a + // reconnect-worthy drop. + this._previousStatuses.delete(address); + await this._tunnelService.disconnect(address); + } + /** * Detect tunnel connections that transitioned from Connected to * Disconnected and schedule an auto-reconnect. diff --git a/src/vs/sessions/contrib/remoteAgentHost/common/agentHostFilter.ts b/src/vs/sessions/contrib/remoteAgentHost/common/agentHostFilter.ts new file mode 100644 index 0000000000000..642626013ce11 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/common/agentHostFilter.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +/** + * Connection status of a host surfaced in the host filter. + */ +export const enum AgentHostFilterConnectionStatus { + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', +} + +/** + * A single host entry the user can scope the sessions list to. + */ +export interface IAgentHostFilterEntry { + /** The {@link ISession.providerId} of the host — stable filter key. */ + readonly providerId: string; + /** Display name for the host. */ + readonly label: string; + /** The raw host address (e.g. `localhost:4321`, `tunnel+abc123`). */ + readonly address: string; + /** Current connection status for this host. */ + readonly status: AgentHostFilterConnectionStatus; +} + +export const IAgentHostFilterService = createDecorator('agentHostFilterService'); + +/** + * Tracks the currently selected agent host used to scope the sessions list + * and other workbench surfaces. The selection is always a valid + * {@link ISession.providerId} of a known host, or `undefined` when no + * hosts are known. + */ +export interface IAgentHostFilterService { + readonly _serviceBrand: undefined; + + /** Fires when {@link selectedProviderId} or {@link hosts} changes. */ + readonly onDidChange: Event; + + /** The currently selected providerId, or `undefined` when no hosts are known. */ + readonly selectedProviderId: string | undefined; + + /** All known hosts the user can switch between. */ + readonly hosts: readonly IAgentHostFilterEntry[]; + + /** + * Update the selection. Ignored if `providerId` does not match a + * known host. + */ + setSelectedProviderId(providerId: string): void; + + /** + * Tear down any existing connection for the given host and start a + * fresh connect attempt. No-op if the host is unknown. + */ + reconnect(providerId: string): void; + + /** + * Tear down the active connection for the given host without forgetting + * the entry. No-op if the host is unknown or already disconnected. + */ + disconnect(providerId: string): void; +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFilterService.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFilterService.test.ts new file mode 100644 index 0000000000000..df29f0b1fa164 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFilterService.test.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { isWeb } from '../../../../../base/common/platform.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { AgentHostFilterService } from '../../browser/agentHostFilterService.js'; +import { AgentHostFilterConnectionStatus } from '../../common/agentHostFilter.js'; + +class StubRemoteProvider { + readonly id: string; + readonly label: string; + readonly remoteAddress: string; + private readonly _status; + readonly connectionStatus: IObservable; + + constructor(address: string, label: string, status = RemoteAgentHostConnectionStatus.Connected) { + this.id = `agenthost-${address}`; + this.label = label; + this.remoteAddress = address; + this._status = observableValue('status', status); + this.connectionStatus = this._status; + } + + setStatus(status: RemoteAgentHostConnectionStatus): void { + this._status.set(status, undefined); + } +} + +class StubSessionsProvidersService implements Partial { + declare readonly _serviceBrand: undefined; + + private readonly _providers = new Map(); + private readonly _onDidChangeProviders = new Emitter(); + readonly onDidChangeProviders = this._onDidChangeProviders.event; + + registerProvider(provider: ISessionsProvider): IDisposable { + this._providers.set(provider.id, provider); + this._onDidChangeProviders.fire({ added: [provider], removed: [] }); + return toDisposable(() => { + if (this._providers.delete(provider.id)) { + this._onDidChangeProviders.fire({ added: [], removed: [provider] }); + } + }); + } + + getProviders(): ISessionsProvider[] { + return Array.from(this._providers.values()); + } + + getProvider(providerId: string): T | undefined { + return this._providers.get(providerId) as T | undefined; + } +} + +class StubRemoteAgentHostService implements Partial { + declare readonly _serviceBrand: undefined; + reconnect(_address: string): void { /* noop */ } +} + +function pid(address: string): string { + return `agenthost-${address}`; +} + +suite('AgentHostFilterService', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(providers: StubSessionsProvidersService, storage = store.add(new InMemoryStorageService())) { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsProvidersService, providers as unknown as ISessionsProvidersService); + instantiationService.stub(IRemoteAgentHostService, new StubRemoteAgentHostService() as unknown as IRemoteAgentHostService); + instantiationService.stub(IStorageService, storage); + return store.add(instantiationService.createInstance(AgentHostFilterService)); + } + + test('defaults to undefined when no selection persisted and no hosts', () => { + const providers = new StubSessionsProvidersService(); + const service = createService(providers); + assert.strictEqual(service.selectedProviderId, undefined); + assert.deepStrictEqual([...service.hosts], []); + }); + + test('defaults based on platform when none persisted', () => { + const providers = new StubSessionsProvidersService(); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:9999', 'Host B') as unknown as ISessionsProvider)); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:4321', 'Host A', RemoteAgentHostConnectionStatus.Disconnected) as unknown as ISessionsProvider)); + const service = createService(providers); + assert.strictEqual(service.selectedProviderId, isWeb ? pid('localhost:4321') : undefined); + }); + + test('surfaces registered remote providers with their connection status', () => { + const providers = new StubSessionsProvidersService(); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:4321', 'Host A') as unknown as ISessionsProvider)); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:9999', 'Host B', RemoteAgentHostConnectionStatus.Disconnected) as unknown as ISessionsProvider)); + const service = createService(providers); + + const hosts = [...service.hosts].map(h => ({ label: h.label, status: h.status, providerId: h.providerId })); + assert.deepStrictEqual(hosts, [ + { label: 'Host A', status: AgentHostFilterConnectionStatus.Connected, providerId: pid('localhost:4321') }, + { label: 'Host B', status: AgentHostFilterConnectionStatus.Disconnected, providerId: pid('localhost:9999') }, + ]); + }); + + test('updates when a provider status changes', () => { + const providers = new StubSessionsProvidersService(); + const hostA = new StubRemoteProvider('localhost:4321', 'Host A'); + store.add(providers.registerProvider(hostA as unknown as ISessionsProvider)); + const service = createService(providers); + + let events = 0; + store.add(service.onDidChange(() => events++)); + + hostA.setStatus(RemoteAgentHostConnectionStatus.Disconnected); + assert.strictEqual(service.hosts[0].status, AgentHostFilterConnectionStatus.Disconnected); + assert.strictEqual(events, 1); + }); + + test('setSelectedProviderId fires change and restores based on platform', () => { + const providers = new StubSessionsProvidersService(); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:4321', 'Host A') as unknown as ISessionsProvider)); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:9999', 'Host B') as unknown as ISessionsProvider)); + const storage = store.add(new InMemoryStorageService()); + const service = createService(providers, storage); + + let events = 0; + store.add(service.onDidChange(() => events++)); + + service.setSelectedProviderId(pid('localhost:9999')); + assert.strictEqual(service.selectedProviderId, pid('localhost:9999')); + assert.strictEqual(events, 1); + + // Recreate service with same storage — selection is restored only on web. + const service2 = createService(providers, storage); + assert.strictEqual(service2.selectedProviderId, isWeb ? pid('localhost:9999') : undefined); + }); + + test('fallback selection depends on platform when selected host disappears', () => { + const providers = new StubSessionsProvidersService(); + const hostA = new StubRemoteProvider('localhost:4321', 'Host A'); + const hostB = new StubRemoteProvider('localhost:9999', 'Host B'); + store.add(providers.registerProvider(hostA as unknown as ISessionsProvider)); + const hostBReg = providers.registerProvider(hostB as unknown as ISessionsProvider); + const service = createService(providers); + + service.setSelectedProviderId(pid('localhost:9999')); + assert.strictEqual(service.selectedProviderId, pid('localhost:9999')); + + // Remove Host B — selection falls back only on web. + hostBReg.dispose(); + assert.strictEqual(service.selectedProviderId, isWeb ? pid('localhost:4321') : undefined); + }); + + test('setSelectedProviderId ignores unknown hosts', () => { + const providers = new StubSessionsProvidersService(); + store.add(providers.registerProvider(new StubRemoteProvider('localhost:4321', 'Host A') as unknown as ISessionsProvider)); + const service = createService(providers); + service.setSelectedProviderId(pid('localhost:4321')); + assert.strictEqual(service.selectedProviderId, pid('localhost:4321')); + service.setSelectedProviderId('agenthost-nonexistent'); + assert.strictEqual(service.selectedProviderId, pid('localhost:4321')); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsGroupModel.ts b/src/vs/sessions/contrib/sessions/browser/sessionsGroupModel.ts deleted file mode 100644 index dd5c47a1657fb..0000000000000 --- a/src/vs/sessions/contrib/sessions/browser/sessionsGroupModel.ts +++ /dev/null @@ -1,250 +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 { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; - -const SESSIONS_GROUPS_STORAGE_KEY = 'sessions.groups'; - -interface ISerializedSessionGroup { - readonly sessionId: string; - readonly chatIds: string[]; - readonly activeChatIndex: number; -} - -export interface ISessionsGroupModelChange { - readonly sessionId: string; -} - -export interface ISessionsGroupModelChatAddedChange { - readonly sessionId: string; - readonly chatId: string; -} - -interface Group { - readonly chatIds: string[]; - activeChatIndex: number; -} - -/** - * Model that tracks which chats belong to which session group. - * Persisted via IStorageService so data survives window reload. - * - * Every group always has at least one chat and an active chat. - * Removing the last chat from a group deletes the group. - */ -export class SessionsGroupModel extends Disposable { - - private readonly _groups = new Map(); - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - private readonly _onDidAddChatToSession = this._register(new Emitter()); - readonly onDidAddChatToSession: Event = this._onDidAddChatToSession.event; - - constructor( - private readonly _storageService: IStorageService, - ) { - super(); - this._load(); - } - - /** - * Returns all session IDs that have groups. - */ - getSessionIds(): string[] { - return [...this._groups.keys()]; - } - - /** - * Returns the chat IDs belonging to a session group, or an empty array - * if the session does not exist. - */ - getChatIds(sessionId: string): readonly string[] { - return this._groups.get(sessionId)?.chatIds ?? []; - } - - /** - * Returns the session ID that contains the given chat, or `undefined` - * if the chat is not in any group. - */ - getSessionIdForChat(chatId: string): string | undefined { - for (const [sessionId, group] of this._groups) { - if (group.chatIds.includes(chatId)) { - return sessionId; - } - } - return undefined; - } - - /** - * Returns the active chat ID for a session group. - * @throws if the session does not exist. - */ - getActiveChatId(sessionId: string): string { - const group = this._groups.get(sessionId); - if (!group) { - throw new Error(`Session group '${sessionId}' does not exist`); - } - return group.chatIds[group.activeChatIndex]; - } - - /** - * Returns whether a session group exists for the given session ID. - */ - hasGroupForSession(sessionId: string): boolean { - return this._groups.has(sessionId); - } - - /** - * Sets the active chat for its session group. The chat must belong to - * a group. If it does not, this is a no-op. - */ - setActiveChatId(chatId: string): void { - const sessionId = this.getSessionIdForChat(chatId); - if (!sessionId) { - return; - } - const group = this._groups.get(sessionId)!; - const idx = group.chatIds.indexOf(chatId); - if (group.activeChatIndex === idx) { - return; - } - group.activeChatIndex = idx; - this._save(); - this._onDidChange.fire({ sessionId }); - } - - /** - * Adds a chat to a session group. Creates the session group if it does - * not exist yet. The first chat added becomes the active chat. - * Adding the same chat twice is a no-op. - */ - addChat(sessionId: string, chatId: string): void { - let group = this._groups.get(sessionId); - if (!group) { - group = { chatIds: [], activeChatIndex: 0 }; - this._groups.set(sessionId, group); - } - if (group.chatIds.includes(chatId)) { - return; - } - group.chatIds.push(chatId); - this._save(); - this._onDidChange.fire({ sessionId }); - this._onDidAddChatToSession.fire({ sessionId, chatId }); - } - - /** - * Removes a chat from its session group. If the chat is not in - * any group this is a no-op. If it was the last chat in the group, - * the group is deleted. - */ - removeChat(chatId: string): void { - for (const [sessionId, group] of this._groups) { - const idx = group.chatIds.indexOf(chatId); - if (idx !== -1) { - group.chatIds.splice(idx, 1); - if (group.chatIds.length === 0) { - this._groups.delete(sessionId); - } else if (group.activeChatIndex >= group.chatIds.length) { - group.activeChatIndex = group.chatIds.length - 1; - } else if (idx < group.activeChatIndex) { - group.activeChatIndex--; - } - this._save(); - this._onDidChange.fire({ sessionId }); - return; - } - } - } - - /** - * Atomically replaces a chat ID in its group, keeping the same position. - * Fires a single change event. No-op if the old chat is not found. - */ - replaceChat(oldChatId: string, newChatId: string): void { - for (const [sessionId, group] of this._groups) { - const idx = group.chatIds.indexOf(oldChatId); - if (idx !== -1) { - group.chatIds[idx] = newChatId; - this._save(); - this._onDidChange.fire({ sessionId }); - return; - } - } - } - - /** - * Deletes an entire session group and all its chat associations. - */ - deleteSession(sessionId: string): void { - if (!this._groups.delete(sessionId)) { - return; - } - this._save(); - this._onDidChange.fire({ sessionId }); - } - - // #region Persistence - - private _load(): void { - const raw = this._storageService.get(SESSIONS_GROUPS_STORAGE_KEY, StorageScope.PROFILE); - if (!raw) { - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return; - } - - if (!Array.isArray(parsed)) { - return; - } - - for (const entry of parsed) { - if ( - typeof entry === 'object' && entry !== null && - typeof (entry as ISerializedSessionGroup).sessionId === 'string' && - Array.isArray((entry as ISerializedSessionGroup).chatIds) - ) { - const chatIds = (entry as ISerializedSessionGroup).chatIds.filter( - (id: unknown): id is string => typeof id === 'string', - ); - if (chatIds.length === 0) { - continue; - } - const sid = (entry as ISerializedSessionGroup).sessionId; - const activeChatIndex = (entry as Record).activeChatIndex; - this._groups.set(sid, { - chatIds, - activeChatIndex: typeof activeChatIndex === 'number' && activeChatIndex >= 0 && activeChatIndex < chatIds.length - ? activeChatIndex - : 0, - }); - } - } - } - - private _save(): void { - const data: ISerializedSessionGroup[] = []; - for (const [sessionId, group] of this._groups) { - data.push({ sessionId, chatIds: group.chatIds, activeChatIndex: group.activeChatIndex }); - } - this._storageService.store( - SESSIONS_GROUPS_STORAGE_KEY, - JSON.stringify(data), - StorageScope.PROFILE, - StorageTarget.MACHINE, - ); - } - - // #endregion -} diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 2bf8add8446ee..6ce00a897d948 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -42,6 +42,7 @@ import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsListModelService } from './sessionsListModelService.js'; +import { IAgentHostFilterService } from '../../../remoteAgentHost/common/agentHostFilter.js'; const $ = DOM.$; @@ -688,6 +689,7 @@ export class SessionsList extends Disposable implements ISessionsList { private readonly options: ISessionsListControlOptions, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, + @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, @@ -828,6 +830,12 @@ export class SessionsList extends Disposable implements ISessionsList { } })); + this._register(this._agentHostFilterService.onDidChange(() => { + if (this.visible) { + this.update(); + } + })); + // Re-update when the active session changes so that a filtered-out // session becomes visible while active and hides again when unselected. // Also mark the newly active session as read. @@ -854,6 +862,10 @@ export class SessionsList extends Disposable implements ISessionsList { // Filter by session type and status let filtered = this.sessions; + const hostFilter = this._agentHostFilterService.selectedProviderId; + if (hostFilter !== undefined) { + filtered = filtered.filter(s => s.providerId === hostFilter); + } if (this.excludedSessionTypes.size > 0) { filtered = filtered.filter(s => !this.excludedSessionTypes.has(s.sessionType)); } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsGroupModel.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsGroupModel.test.ts deleted file mode 100644 index 53d8afb3a5c00..0000000000000 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsGroupModel.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { SessionsGroupModel } from '../../browser/sessionsGroupModel.js'; - -const STORAGE_KEY = 'sessions.groups'; - -suite('SessionsGroupModel', () => { - - let store: DisposableStore; - let storageService: InMemoryStorageService; - - setup(() => { - store = new DisposableStore(); - storageService = store.add(new InMemoryStorageService()); - }); - - teardown(() => { - store.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - function createModel(): SessionsGroupModel { - return store.add(new SessionsGroupModel(storageService)); - } - - test('starts empty', () => { - const model = createModel(); - assert.deepStrictEqual(model.getSessionIds(), []); - }); - - test('addChat creates group with first chat as active', () => { - const model = createModel(); - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.addChat('s1', 'c1'); - - assert.deepStrictEqual(model.getSessionIds(), ['s1']); - assert.deepStrictEqual(model.getChatIds('s1'), ['c1']); - assert.strictEqual(model.getActiveChatId('s1'), 'c1'); - assert.deepStrictEqual(fired, ['s1']); - }); - - test('addChat appends to existing group preserving active', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.addChat('s1', 'c2'); - - assert.deepStrictEqual(model.getChatIds('s1'), ['c1', 'c2']); - assert.strictEqual(model.getActiveChatId('s1'), 'c1'); - assert.deepStrictEqual(fired, ['s1']); - }); - - test('addChat is a no-op for duplicate chat', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.addChat('s1', 'c1'); - - assert.deepStrictEqual(model.getChatIds('s1'), ['c1']); - assert.deepStrictEqual(fired, []); - }); - - test('getChatIds returns empty for unknown session', () => { - const model = createModel(); - assert.deepStrictEqual(model.getChatIds('unknown'), []); - }); - - test('getSessionIdForChat finds correct session', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s2', 'c2'); - - assert.strictEqual(model.getSessionIdForChat('c1'), 's1'); - assert.strictEqual(model.getSessionIdForChat('c2'), 's2'); - }); - - test('getSessionIdForChat returns undefined for unknown chat', () => { - const model = createModel(); - assert.strictEqual(model.getSessionIdForChat('x'), undefined); - }); - - test('getActiveChatId throws for unknown session', () => { - const model = createModel(); - assert.throws(() => model.getActiveChatId('x')); - }); - - test('setActiveChatId changes active chat and fires event', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s1', 'c2'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.setActiveChatId('c2'); - - assert.strictEqual(model.getActiveChatId('s1'), 'c2'); - assert.deepStrictEqual(fired, ['s1']); - }); - - test('setActiveChatId is a no-op for chat not in any group', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.setActiveChatId('c999'); - - assert.strictEqual(model.getActiveChatId('s1'), 'c1'); - assert.deepStrictEqual(fired, []); - }); - - test('setActiveChatId is a no-op when already active', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.setActiveChatId('c1'); - - assert.deepStrictEqual(fired, []); - }); - - test('removeChat removes chat from its group', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s1', 'c2'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.removeChat('c1'); - - assert.deepStrictEqual(model.getChatIds('s1'), ['c2']); - assert.strictEqual(model.getSessionIdForChat('c1'), undefined); - assert.deepStrictEqual(fired, ['s1']); - }); - - test('removeChat deletes group when last chat is removed', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.removeChat('c1'); - - assert.deepStrictEqual(model.getSessionIds(), []); - assert.deepStrictEqual(fired, ['s1']); - }); - - test('removeChat adjusts active index when active chat is removed', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s1', 'c2'); - model.addChat('s1', 'c3'); - model.setActiveChatId('c3'); - - model.removeChat('c3'); - - assert.strictEqual(model.getActiveChatId('s1'), 'c2'); - }); - - test('removeChat adjusts active index when earlier chat is removed', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s1', 'c2'); - model.addChat('s1', 'c3'); - model.setActiveChatId('c3'); - - model.removeChat('c1'); - - assert.strictEqual(model.getActiveChatId('s1'), 'c3'); - }); - - test('removeChat preserves active when later chat is removed', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s1', 'c2'); - model.addChat('s1', 'c3'); - model.setActiveChatId('c1'); - - model.removeChat('c3'); - - assert.strictEqual(model.getActiveChatId('s1'), 'c1'); - }); - - test('removeChat is a no-op for unknown chat', () => { - const model = createModel(); - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.removeChat('x'); - - assert.deepStrictEqual(fired, []); - }); - - test('deleteSession removes group entirely', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s1', 'c2'); - - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.deleteSession('s1'); - - assert.deepStrictEqual(model.getSessionIds(), []); - assert.deepStrictEqual(model.getChatIds('s1'), []); - assert.strictEqual(model.getSessionIdForChat('c1'), undefined); - assert.deepStrictEqual(fired, ['s1']); - }); - - test('deleteSession is a no-op for unknown session', () => { - const model = createModel(); - const fired: string[] = []; - store.add(model.onDidChange(e => fired.push(e.sessionId))); - - model.deleteSession('x'); - - assert.deepStrictEqual(fired, []); - }); - - test('data persists across instances via storage', () => { - const model1 = createModel(); - model1.addChat('s1', 'c1'); - model1.addChat('s1', 'c2'); - model1.addChat('s2', 'c3'); - model1.setActiveChatId('c2'); - model1.dispose(); - - const model2 = createModel(); - assert.deepStrictEqual(model2.getSessionIds(), ['s1', 's2']); - assert.deepStrictEqual(model2.getChatIds('s1'), ['c1', 'c2']); - assert.deepStrictEqual(model2.getChatIds('s2'), ['c3']); - assert.strictEqual(model2.getActiveChatId('s1'), 'c2'); - assert.strictEqual(model2.getActiveChatId('s2'), 'c3'); - }); - - test('deletion persists across instances', () => { - const model1 = createModel(); - model1.addChat('s1', 'c1'); - model1.deleteSession('s1'); - model1.dispose(); - - const model2 = createModel(); - assert.deepStrictEqual(model2.getSessionIds(), []); - }); - - test('removeChat last-chat deletion persists', () => { - const model1 = createModel(); - model1.addChat('s1', 'c1'); - model1.removeChat('c1'); - model1.dispose(); - - const model2 = createModel(); - assert.deepStrictEqual(model2.getSessionIds(), []); - }); - - test('handles invalid JSON in storage gracefully', () => { - storageService.store(STORAGE_KEY, '{bad json', StorageScope.PROFILE, StorageTarget.MACHINE); - const model = createModel(); - assert.deepStrictEqual(model.getSessionIds(), []); - }); - - test('handles non-array JSON in storage gracefully', () => { - storageService.store(STORAGE_KEY, '{"not":"array"}', StorageScope.PROFILE, StorageTarget.MACHINE); - const model = createModel(); - assert.deepStrictEqual(model.getSessionIds(), []); - }); - - test('handles malformed entries in storage gracefully', () => { - const data = [ - { sessionId: 'good', chatIds: ['c1'], activeChatIndex: 0 }, - { sessionId: 123, chatIds: ['c2'] }, // bad sessionId type - { chatIds: ['c3'] }, // missing sessionId - { sessionId: 'mixed', chatIds: ['ok', 42] }, // mixed chatIds types - { sessionId: 'empty', chatIds: [] }, // empty chatIds skipped - null, // null entry - ]; - storageService.store(STORAGE_KEY, JSON.stringify(data), StorageScope.PROFILE, StorageTarget.MACHINE); - - const model = createModel(); - - assert.deepStrictEqual(model.getSessionIds(), ['good', 'mixed']); - assert.deepStrictEqual(model.getChatIds('good'), ['c1']); - assert.deepStrictEqual(model.getChatIds('mixed'), ['ok']); - assert.strictEqual(model.getActiveChatId('good'), 'c1'); - assert.strictEqual(model.getActiveChatId('mixed'), 'ok'); - }); - - test('handles invalid activeChatIndex in storage gracefully', () => { - const data = [ - { sessionId: 's1', chatIds: ['c1', 'c2'], activeChatIndex: 1 }, - { sessionId: 's2', chatIds: ['c3'], activeChatIndex: 5 }, // out of range - { sessionId: 's3', chatIds: ['c4'], activeChatIndex: -1 }, // negative - { sessionId: 's4', chatIds: ['c5'] }, // missing - ]; - storageService.store(STORAGE_KEY, JSON.stringify(data), StorageScope.PROFILE, StorageTarget.MACHINE); - - const model = createModel(); - - assert.strictEqual(model.getActiveChatId('s1'), 'c2'); - assert.strictEqual(model.getActiveChatId('s2'), 'c3'); - assert.strictEqual(model.getActiveChatId('s3'), 'c4'); - assert.strictEqual(model.getActiveChatId('s4'), 'c5'); - }); - - test('removeChat updates storage', () => { - const model = createModel(); - model.addChat('s1', 'c1'); - model.addChat('s1', 'c2'); - model.removeChat('c1'); - model.dispose(); - - const model2 = createModel(); - assert.deepStrictEqual(model2.getChatIds('s1'), ['c2']); - }); -}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index d4a45aeae6795..935209325e3f4 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -262,7 +262,8 @@ import '../workbench/contrib/sash/browser/sash.contribution.js'; import '../workbench/contrib/git/browser/git.contributions.js'; // SCM -import '../workbench/contrib/scm/browser/scm.contribution.js'; +import '../workbench/contrib/scm/browser/quickDiff.contribution.js'; +import '../workbench/contrib/scm/browser/scm.service.contribution.js'; // Debug (service) import '../workbench/contrib/debug/browser/debug.service.contribution.js'; @@ -384,9 +385,6 @@ import '../workbench/contrib/languageStatus/browser/languageStatus.contribution. // Authentication import '../workbench/contrib/authentication/browser/authentication.contribution.js'; -// User Data Sync -import '../workbench/contrib/userDataSync/browser/userDataSync.contribution.js'; - // User Data Profiles import '../workbench/contrib/userDataProfile/browser/userDataProfile.contribution.js'; @@ -460,6 +458,7 @@ import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/copilotChatSessions/browser/copilotChatSessions.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/views/sessionsListModelService.js'; +import './contrib/remoteAgentHost/browser/agentHostFilterService.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changes/browser/changesView.contribution.js'; import './contrib/layout/browser/layout.contribution.js'; diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 3175f775b7ee2..f2764417d6ead 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -139,9 +139,6 @@ import '../workbench/contrib/terminal/electron-browser/terminal.contribution.js' // Themes import '../workbench/contrib/themes/browser/themes.test.contribution.js'; import '../workbench/services/themes/electron-browser/themes.contribution.js'; -// User Data Sync -import '../workbench/contrib/userDataSync/electron-browser/userDataSync.contribution.js'; - // Tags import '../workbench/contrib/tags/electron-browser/workspaceTagsService.js'; import '../workbench/contrib/tags/electron-browser/tags.contribution.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 464cc81b196b3..1dde9c9551d18 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -156,6 +156,9 @@ import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.j import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; +// Host filter dropdown in the titlebar (scopes the sessions list to a host) +import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; + // TODO: support agent feedback in web import './contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.js'; import '../workbench/contrib/webview/browser/webview.web.contribution.js'; diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 21341b82e79b0..e897e8057ad82 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2132,6 +2132,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseCodeCitationPart: extHostTypes.ChatResponseCodeCitationPart, ChatResponseCodeblockUriPart: extHostTypes.ChatResponseCodeblockUriPart, ChatResponseWarningPart: extHostTypes.ChatResponseWarningPart, + ChatResponseInfoPart: extHostTypes.ChatResponseInfoPart, ChatResponseTextEditPart: extHostTypes.ChatResponseTextEditPart, ChatResponseNotebookEditPart: extHostTypes.ChatResponseNotebookEditPart, ChatResponseWorkspaceEditPart: extHostTypes.ChatResponseWorkspaceEditPart, diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 6be676e44dbc6..58b98ea5a42df 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -224,6 +224,14 @@ export class ChatAgentResponseStream { _report(dto); return this; }, + info(value) { + throwIfDone(this.progress); + checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); + const part = new extHostTypes.ChatResponseInfoPart(value); + const dto = typeConvert.ChatResponseInfoPart.from(part); + _report(dto); + return this; + }, reference(value, iconPath) { return this.reference2(value, iconPath); }, diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index a169d81c62616..ac51bc11a21a7 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -544,6 +544,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio when: g.when, icon: g.icon, commands: g.commands, + kind: g.kind, })); const resource = inputState.sessionResource ?? inputState.untitledSessionResource; if (resource) { @@ -633,7 +634,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } const session = await provider.provider.provideChatSessionContent(sessionResource, token, { - sessionOptions: context?.initialSessionOptions ?? [], inputState, }); if (token.isCancellationRequested) { @@ -1089,7 +1089,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio prompt: request.prompt, command: request.command }, - sessionOptions: request.initialSessionOptions ?? [], inputState, }, token); if (!item) { @@ -1159,6 +1158,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio when: g.when, icon: g.icon, commands: g.commands, + kind: g.kind, })); } } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 1906d97f8ad47..f4441fbc648d7 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -43,7 +43,7 @@ import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; @@ -2852,6 +2852,18 @@ export namespace ChatResponseWarningPart { } } +export namespace ChatResponseInfoPart { + export function from(part: vscode.ChatResponseInfoPart): Dto { + return { + kind: 'info', + content: MarkdownString.from(part.value) + }; + } + export function to(part: Dto): vscode.ChatResponseInfoPart { + return new types.ChatResponseInfoPart(part.content.value); + } +} + export namespace ChatResponseExtensionsPart { export function from(part: vscode.ChatResponseExtensionsPart): Dto { return { @@ -3354,6 +3366,8 @@ export namespace ChatResponsePart { return ChatResponseCodeblockUriPart.from(part); } else if (part instanceof types.ChatResponseWarningPart) { return ChatResponseWarningPart.from(part); + } else if (part instanceof types.ChatResponseInfoPart) { + return ChatResponseInfoPart.from(part); } else if (part instanceof types.ChatResponseConfirmationPart) { return ChatResponseConfirmationPart.from(part); } else if (part instanceof types.ChatResponseQuestionCarouselPart) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 8f2008735ebbe..fd9262596f8b9 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3273,6 +3273,17 @@ export class ChatResponseWarningPart { } } +export class ChatResponseInfoPart { + value: vscode.MarkdownString; + constructor(value: string | vscode.MarkdownString) { + if (typeof value !== 'string' && value.isTrusted === true) { + throw new Error('The boolean form of MarkdownString.isTrusted is NOT supported for chat participants.'); + } + + this.value = typeof value === 'string' ? new MarkdownString(value) : value; + } +} + export class ChatResponseCommandButtonPart { value: vscode.Command; constructor(value: vscode.Command) { diff --git a/src/vs/workbench/common/editor/editorGroupModel.ts b/src/vs/workbench/common/editor/editorGroupModel.ts index ec14952a790ff..1e0507653bd3b 100644 --- a/src/vs/workbench/common/editor/editorGroupModel.ts +++ b/src/vs/workbench/common/editor/editorGroupModel.ts @@ -188,7 +188,7 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { //#region events - private readonly _onDidModelChange = this._register(new Emitter({ leakWarningThreshold: 500 /* increased for users with hundreds of inputs opened */ })); + private readonly _onDidModelChange = this._register(new Emitter({ leakWarningThreshold: 500, leakWarningName: 'EditorGroupModel._onDidModelChange' /* increased for users with hundreds of inputs opened */ })); readonly onDidModelChange = this._onDidModelChange.event; //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 47e88ae6aebf5..78e9a277ec66e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -458,10 +458,7 @@ export class OpenPermissionPickerAction extends Action2 { ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), ChatContextKeys.inQuickChat.negate(), - ContextKeyExpr.or( - ChatContextKeys.lockedToCodingAgent.negate(), - ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Background), - ), + ChatContextKeys.lockedCodingAgentId.notEqualsTo(AgentSessionProviders.Cloud), ) } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts index 30b6efb29b2c0..2e43c35e95454 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts @@ -13,12 +13,12 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IFileService } from '../../../../../platform/files/common/files.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IAgentPluginRepositoryService } from '../../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../../common/plugins/pluginInstallService.js'; import { type IMarketplaceReference, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from '../../common/plugins/pluginMarketplaceService.js'; -import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { InstalledAgentPluginsViewId } from '../agentPluginsView.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; @@ -71,6 +71,7 @@ class InstallFromSourceAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const quickInputService = accessor.get(IQuickInputService); const pluginInstallService = accessor.get(IPluginInstallService); + const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService); const store = new DisposableStore(); const inputBox = store.add(quickInputService.createInputBox()); @@ -83,8 +84,11 @@ class InstallFromSourceAction extends Action2 { inputBox.validationMessage = undefined; })); + let installing = false; store.add(inputBox.onDidHide(() => { - store.dispose(); + if (!installing) { + store.dispose(); + } })); store.add(inputBox.onDidAccept(async () => { @@ -103,6 +107,7 @@ class InstallFromSourceAction extends Action2 { // Show busy state and prevent concurrent installs. inputBox.busy = true; inputBox.enabled = false; + installing = true; try { // Hide the input box so it doesn't conflict with trust/progress dialogs. inputBox.hide(); @@ -117,12 +122,16 @@ class InstallFromSourceAction extends Action2 { } else { const ref = parseMarketplaceReference(source); if (ref) { - accessor.get(IExtensionsWorkbenchService).openSearch(`@agentPlugins ${ref.displayLabel}`); + extensionsWorkbenchService.openSearch(`@agentPlugins ${ref.displayLabel}`); } + store.dispose(); } } finally { - inputBox.busy = false; - inputBox.enabled = true; + installing = false; + if (!store.isDisposed) { + inputBox.busy = false; + inputBox.enabled = true; + } } })); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f01e22e33658d..422268fb8f4b9 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -168,10 +168,10 @@ import { IPluginGitService } from '../common/plugins/pluginGitService.js'; import { PluginInstallService } from './pluginInstallService.js'; import './promptSyntax/promptCodingAgentActionContribution.js'; import './promptSyntax/promptToolsCodeLensProvider.js'; +import { ChatSessionOptionSlashCommandsContribution, ChatSlashCommandsContribution } from './chatSlashCommands.js'; import './planReviewFeedback/planReviewFeedbackEditorContribution.js'; import { registerPlanReviewFeedbackEditorActions } from './planReviewFeedback/planReviewFeedbackEditorActions.js'; import { IPlanReviewFeedbackService, PlanReviewFeedbackService } from './planReviewFeedback/planReviewFeedbackService.js'; -import { ChatSlashCommandsContribution } from './chatSlashCommands.js'; import { PluginUrlHandler } from './pluginUrlHandler.js'; import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; @@ -2105,6 +2105,7 @@ registerWorkbenchContribution2(ChatDebugResolverContribution.ID, ChatDebugResolv registerWorkbenchContribution2(PromptsDebugContribution.ID, PromptsDebugContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashCommandsContribution.ID, ChatSlashCommandsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatSessionOptionSlashCommandsContribution.ID, ChatSessionOptionSlashCommandsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index f75404ddde023..559af6a793c39 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -5,16 +5,18 @@ import { timeout } from '../../../../base/common/async.js'; import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import * as nls from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { 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 { IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../common/constants.js'; import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js'; import { ChatSubmitAction, OpenModePickerAction, OpenModelPickerAction } from './actions/chatExecuteActions.js'; @@ -318,3 +320,93 @@ export class ChatSlashCommandsContribution extends Disposable { })); } } + +/** + * Registers slash commands declared by chat session providers via + * {@link IChatSessionProviderOptionItem.slashCommand}. Each slash command is + * scoped to its contributing session type via a `chatSessionType == X` `when` + * clause, executes immediately, and updates the session option corresponding + * to its declaring item — so e.g. `/yolo` switches the active permission mode + * without sending a chat request. + */ +export class ChatSessionOptionSlashCommandsContribution extends Disposable { + + static readonly ID = 'workbench.contrib.chatSessionOptionSlashCommands'; + + private readonly _registrationsByType = this._register(new DisposableMap()); + + constructor( + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IChatSlashCommandService private readonly slashCommandService: IChatSlashCommandService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this._register(this.chatSessionsService.onDidChangeOptionGroups(chatSessionType => { + this.refreshForSessionType(chatSessionType); + })); + } + + private refreshForSessionType(chatSessionType: string): void { + // Always tear down the previous registrations for this type before re-adding, + // so renames / removals are honored. + this._registrationsByType.deleteAndDispose(chatSessionType); + + const groups = this.chatSessionsService.getOptionGroupsForSessionType(chatSessionType); + if (!groups || groups.length === 0) { + return; + } + + const store = new DisposableStore(); + const seen = new Set(); + const whenClause = ChatContextKeys.chatSessionType.isEqualTo(chatSessionType); + + for (const group of groups) { + for (const item of group.items) { + const name = item.slashCommand?.trim(); + if (!name) { + continue; + } + if (seen.has(name)) { + this.logService.warn(`[ChatSessionOptionSlashCommands] Skipping duplicate slash command '${name}' contributed by session type '${chatSessionType}'.`); + continue; + } + if (this.slashCommandService.hasCommand(name)) { + this.logService.warn(`[ChatSessionOptionSlashCommands] Slash command '${name}' contributed by session type '${chatSessionType}' is already registered; skipping.`); + continue; + } + seen.add(name); + store.add(this.registerOne(chatSessionType, group, item, name, whenClause)); + } + } + + if (store.isDisposed || seen.size === 0) { + store.dispose(); + return; + } + this._registrationsByType.set(chatSessionType, store); + } + + private registerOne( + chatSessionType: string, + group: IChatSessionProviderOptionGroup, + item: IChatSessionProviderOptionItem, + name: string, + whenClause: ReturnType, + ) { + return this.slashCommandService.registerSlashCommand({ + command: name, + detail: item.description ?? nls.localize('chatSessionOption.slashCommand.detail', "Switch to '{0}'", item.name), + sortText: `z1_${name}`, + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat], + when: whenClause, + }, async (_prompt, _progress, _history, _location, sessionResource) => { + if (!sessionResource) { + return; + } + this.chatSessionsService.setSessionOption(sessionResource, group.id, item); + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 93d1c82ef0bde..e30045006ea64 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -6,6 +6,7 @@ import { $, append, EventType, addDisposableListener, EventHelper, disposableWindowInterval, getWindow } from '../../../../../base/browser/dom.js'; import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; +import { renderLabelWithIcons } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { IAction, toAction, WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../../base/common/actions.js'; @@ -13,7 +14,8 @@ import { CancellationToken, cancelOnDispose } from '../../../../../base/common/c import { Codicon } from '../../../../../base/common/codicons.js'; import { safeIntl } from '../../../../../base/common/date.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { MutableDisposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { parseLinkedText } from '../../../../../base/common/linkedText.js'; import { language } from '../../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { isObject } from '../../../../../base/common/types.js'; @@ -29,6 +31,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IHoverService, nativeHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { Link } from '../../../../../platform/opener/browser/link.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { defaultButtonStyles, defaultCheckboxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -37,6 +40,7 @@ import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/edi import { IChatEntitlementService, ChatEntitlementService, ChatEntitlement, IQuotaSnapshot, getChatPlanName } from '../../../../services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { isNewUser } from './chatStatus.js'; +import { IChatStatusItemService, ChatStatusEntry } from './chatStatusItemService.js'; import product from '../../../../../platform/product/common/product.js'; import { contrastBorder, inputValidationErrorBorder, inputValidationInfoBorder, inputValidationWarningBorder, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { Color } from '../../../../../base/common/color.js'; @@ -134,6 +138,7 @@ export class ChatStatusDashboard extends DomWidget { constructor( private readonly options: IChatStatusDashboardOptions | undefined, @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @IChatStatusItemService private readonly chatStatusItemService: IChatStatusItemService, @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IEditorService private readonly editorService: IEditorService, @@ -257,6 +262,30 @@ export class ChatStatusDashboard extends DomWidget { this.renderInlineSuggestionsContent(this.element, token, updatePromise); } + // Contributions + { + for (const item of this.chatStatusItemService.getEntries()) { + this.element.appendChild($('hr')); + + const itemDisposables = this._store.add(new MutableDisposable()); + + let rendered = this.renderContributedChatStatusItem(item); + itemDisposables.value = rendered.disposables; + this.element.appendChild(rendered.element); + + this._store.add(this.chatStatusItemService.onDidChange(e => { + if (e.entry.id === item.id) { + const previousElement = rendered.element; + + rendered = this.renderContributedChatStatusItem(e.entry); + itemDisposables.value = rendered.disposables; + + previousElement.replaceWith(rendered.element); + } + })); + } + } + // New to Chat / Signed out { const newUser = isNewUser(this.chatEntitlementService); @@ -460,6 +489,47 @@ export class ChatStatusDashboard extends DomWidget { } } + private renderContributedChatStatusItem(item: ChatStatusEntry): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + + const itemElement = $('div.contribution'); + + const headerLabel = typeof item.label === 'string' ? item.label : item.label.label; + const headerLink = typeof item.label === 'string' ? undefined : item.label.link; + this.renderHeader(itemElement, disposables, headerLabel, headerLink ? toAction({ + id: 'workbench.action.openChatStatusItemLink', + label: localize('learnMore', "Learn More"), + tooltip: localize('learnMore', "Learn More"), + class: ThemeIcon.asClassName(Codicon.linkExternal), + run: () => this.runCommandAndClose(() => this.openerService.open(URI.parse(headerLink))), + }) : undefined); + + const itemBody = itemElement.appendChild($('div.body')); + + const description = itemBody.appendChild($('span.description')); + this.renderTextPlus(description, item.description, disposables); + + if (item.detail) { + const separator = itemBody.appendChild($('span.separator')); + separator.textContent = '\u2014'; + const detail = itemBody.appendChild($('span.detail-item')); + this.renderTextPlus(detail, item.detail, disposables); + } + + return { element: itemElement, disposables }; + } + + private renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void { + for (const node of parseLinkedText(text).nodes) { + if (typeof node === 'string') { + const parts = renderLabelWithIcons(node); + target.append(...parts); + } else { + store.add(new Link(target, node, undefined, this.hoverService, this.openerService)); + } + } + } + private runCommandAndClose(commandOrFn: string | ((...args: unknown[]) => void), ...args: unknown[]): void { if (typeof commandOrFn === 'function') { commandOrFn(...args); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index 12aac00f23304..71437852958e7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css @@ -383,3 +383,29 @@ .chat-status-bar-entry-tooltip .snooze-completions .snooze-action-bar { margin-left: auto; } + +.chat-status-bar-entry-tooltip .contribution .body { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + overflow: hidden; + min-width: 0; +} + +.chat-status-bar-entry-tooltip .contribution .body .description, +.chat-status-bar-entry-tooltip .contribution .body .detail-item { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.chat-status-bar-entry-tooltip .contribution .body .detail-item { + color: var(--vscode-descriptionForeground); +} + +.chat-status-bar-entry-tooltip .contribution .body .separator { + opacity: 0.6; + padding: 0 2px; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index e7f303cfd9dd3..31951d2497c67 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -132,7 +132,10 @@ export class CodeBlockPart extends Disposable { } public readonly editor: CodeEditorWidget; - protected readonly toolbar: MenuWorkbenchToolBar; + private toolbar: MenuWorkbenchToolBar | undefined; + private _toolbarFactory: (() => MenuWorkbenchToolBar) | undefined; + private _pendingToolbarAriaLabel: string | undefined; + private _pendingToolbarContext: ICodeBlockActionContext | undefined; private readonly contextKeyService: IContextKeyService; public readonly element: HTMLElement; @@ -146,6 +149,7 @@ export class CodeBlockPart extends Disposable { private currentScrollWidth = 0; private lastLayoutWidth: number | undefined; private _isHovered = false; + private _isDropdownVisible = false; private _toolbarElement!: HTMLElement; private isDisposed = false; @@ -206,13 +210,18 @@ export class CodeBlockPart extends Disposable { }); const toolbarElement = dom.append(this.element, $('.interactive-result-code-block-toolbar')); + this._toolbarElement = toolbarElement; const editorScopedService = this._register(this.editor.contextKeyService.createScoped(toolbarElement)); const editorScopedInstantiationService = this._register(scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService]))); - this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarElement, menuId, { + // The toolbar itself creates listeners on the menu service and shared + // context key service. In large responses there can be many code + // blocks, so defer creation until the user actually interacts with + // this code block (hover, editor focus, or screen reader mode). + this._toolbarFactory = () => editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarElement, menuId, { menuOptions: { shouldForwardArgs: true } - })); + }); const vulnsContainer = dom.append(this.element, $('.interactive-result-vulns')); const vulnsHeaderElement = dom.append(vulnsContainer, $('.interactive-result-vulns-header', undefined)); @@ -239,12 +248,6 @@ export class CodeBlockPart extends Disposable { // this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded); })); - let isDropdownVisible = false; - this._register(this.toolbar.onDidChangeDropdownVisibility(e => { - isDropdownVisible = e; - toolbarElement.classList.toggle('force-visibility', e || this._isHovered); - })); - // Track hover state via JS so the toolbar remains visible and clickable // even when the code block DOM element is briefly detached and reattached // during streaming re-renders. CSS :hover is lost when an element leaves @@ -256,14 +259,14 @@ export class CodeBlockPart extends Disposable { this._register(dom.addDisposableListener(this.element, 'mouseenter', () => { this._isHovered = true; toolbarElement.classList.add('force-visibility'); + this._ensureToolbar(); })); this._register(dom.addDisposableListener(this.element, 'mouseleave', () => { this._isHovered = false; - if (!isDropdownVisible) { + if (!this._isDropdownVisible) { toolbarElement.classList.remove('force-visibility'); } })); - this._toolbarElement = toolbarElement; this._configureForScreenReader(); this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this._configureForScreenReader())); @@ -292,6 +295,9 @@ export class CodeBlockPart extends Disposable { })); this._register(this.editor.onDidFocusEditorWidget(() => { this.element.classList.add('focused'); + // Editor focus puts the code block into keyboard interaction range; + // create the toolbar so Tab can reach it. + this._ensureToolbar(); WordHighlighterContribution.get(this.editor)?.restoreViewState(true); })); this._register(Event.any( @@ -371,12 +377,63 @@ export class CodeBlockPart extends Disposable { this.editor.updateOptions({ padding: { top: this.verticalPadding, bottom: bottomPadding } }); } + private _ensureToolbar(): MenuWorkbenchToolBar | undefined { + if (this.isDisposed) { + return undefined; + } + // If the current render explicitly hid the toolbar, don't pay the cost + // of creating it (and adding listeners on the shared menu / context + // key services). It will be created later if a render makes it visible. + if (this.currentCodeBlockData?.renderOptions?.hideToolbar) { + return undefined; + } + if (!this.toolbar) { + const factory = this._toolbarFactory; + if (!factory) { + return undefined; + } + this._toolbarFactory = undefined; + const toolbar = this._register(factory()); + this.toolbar = toolbar; + + this._register(toolbar.onDidChangeDropdownVisibility(e => { + this._isDropdownVisible = e; + this._toolbarElement.classList.toggle('force-visibility', e || this._isHovered); + })); + + if (this._pendingToolbarAriaLabel !== undefined) { + toolbar.setAriaLabel(this._pendingToolbarAriaLabel); + this._pendingToolbarAriaLabel = undefined; + } + if (this._pendingToolbarContext !== undefined) { + toolbar.context = this._pendingToolbarContext; + this._pendingToolbarContext = undefined; + } + } + return this.toolbar; + } + private _configureForScreenReader(): void { - const toolbarElt = this.toolbar.getElement(); + const hideToolbar = !!this.currentCodeBlockData?.renderOptions?.hideToolbar; if (this.accessibilityService.isScreenReaderOptimized()) { - toolbarElt.style.display = 'block'; + if (hideToolbar) { + // hideToolbar is authoritative; don't reveal the wrapper just + // because SR mode is on. + dom.hide(this._toolbarElement); + } else { + this._toolbarElement.style.display = 'block'; + // Screen readers need the toolbar DOM to exist so it can be + // announced and navigated, but only create it once render data + // is available so pooled or reset instances don't eagerly + // attach toolbar listeners. + if (this.currentCodeBlockData) { + this._ensureToolbar(); + } + } + } else if (hideToolbar) { + dom.hide(this._toolbarElement); } else { - toolbarElt.style.display = ''; + this._toolbarElement.style.display = ''; } } @@ -460,11 +517,23 @@ export class CodeBlockPart extends Disposable { }); } this.layout(width); - this.toolbar.setAriaLabel(localize('chat.codeBlockToolbarLabel', "Code block {0}", data.codeBlockIndex + 1)); + const toolbarAriaLabel = localize('chat.codeBlockToolbarLabel', "Code block {0}", data.codeBlockIndex + 1); + if (this.toolbar) { + this.toolbar.setAriaLabel(toolbarAriaLabel); + } else { + this._pendingToolbarAriaLabel = toolbarAriaLabel; + } if (data.renderOptions?.hideToolbar) { - dom.hide(this.toolbar.getElement()); + dom.hide(this._toolbarElement); } else { - dom.show(this.toolbar.getElement()); + dom.show(this._toolbarElement); + // In screen reader mode the toolbar must exist in the DOM so it + // can be announced and Tab-navigated. If a previous render hid + // the toolbar, _ensureToolbar would have early-exited; create + // it now that it is visible. + if (this.accessibilityService.isScreenReaderOptimized()) { + this._ensureToolbar(); + } } if (data.vulns?.length && isResponseVM(data.element)) { @@ -544,14 +613,19 @@ export class CodeBlockPart extends Disposable { return; } - this.toolbar.context = { + const context: ICodeBlockActionContext = { code: textModel.getTextBuffer().getValueInRange(textModel.getFullModelRange(), EndOfLinePreference.TextDefined), codeBlockIndex: data.codeBlockIndex, element: data.element, languageId: textModel.getLanguageId(), codemapperUri: data.codemapperUri, chatSessionResource: data.chatSessionResource - } satisfies ICodeBlockActionContext; + }; + if (this.toolbar) { + this.toolbar.context = context; + } else { + this._pendingToolbarContext = context; + } this.resourceContextKey.set(textModel.uri); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css index 85d1b456c8197..e550cddca7829 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css @@ -151,6 +151,29 @@ opacity: 0.4; } + .monaco-button.chat-tool-carousel-header-button { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-icon-foreground) !important; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + .monaco-button.chat-tool-carousel-header-button:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } + + &.chat-tool-carousel-content-expanded { + max-height: min(650px, 70vh); + } + .monaco-list:focus { outline: none !important; } @@ -211,6 +234,12 @@ max-height: min(200px, 30vh); } + .chat-tool-carousel-content-expanded & { + .chat-confirmation-widget-message { + max-height: min(500px, 55vh); + } + } + .chat-confirmation-message-terminal-editor .interactive-result-code-block { background-color: var(--vscode-sideBar-background); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts index 72251af8125f2..f07e7f4970f19 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts @@ -12,6 +12,7 @@ import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js import { KeyCode } from '../../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../../base/common/observable.js'; +import { generateUuid } from '../../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../../nls.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; import { IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; @@ -19,7 +20,10 @@ import { ChatToolInvocationPart } from './chatToolInvocationPart.js'; import '../media/chatToolConfirmationCarousel.css'; const COLLAPSED_CAROUSEL_MAX_HEIGHT = 300; +const COLLAPSED_MESSAGE_MAX_HEIGHT = 200; +const COLLAPSED_CODE_BLOCK_MAX_HEIGHT = 150; const MIN_CAROUSEL_MAX_HEIGHT = 80; +const EXPANDABLE_CONTENT_SELECTOR = '.interactive-result-editor, .chat-markdown-part.rendered-markdown'; export type ToolInvocationPartFactory = (tool: IChatToolInvocation) => ChatToolInvocationPart; @@ -53,8 +57,13 @@ export class ChatToolConfirmationCarouselPart extends Disposable { private readonly prevButton: Button; private readonly nextButton: Button; private readonly allowAllButton: Button; + private readonly expandContentButton: Button; private readonly dismissButton: Button; private readonly activeContentDisposables: DisposableStore; + private readonly contentResizeObserver: dom.DisposableResizeObserver; + private readonly updateContentExpansionStateScheduler: dom.AnimationFrameScheduler; + private _isContentExpanded = false; + private canExpandContent = false; private maxHeight: number | undefined; constructor( @@ -87,14 +96,25 @@ export class ChatToolConfirmationCarouselPart extends Disposable { this.collapsedTitle = elements.collapsedTitle; this.agentLabel = elements.agentLabel; this.contentContainer = elements.content; + this.contentContainer.id = generateUuid(); this.stepIndicator = elements.stepIndicator; this.activeContentDisposables = this._register(new DisposableStore()); + this.updateContentExpansionStateScheduler = this._register(new dom.AnimationFrameScheduler(this.domNode, () => this.updateContentExpansionState())); + this.contentResizeObserver = this._register(new dom.DisposableResizeObserver(() => this.updateContentExpansionStateScheduler.schedule())); + this._register(this.contentResizeObserver.observe(this.contentContainer)); this.allowAllButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, small: true })); this.allowAllButton.element.classList.add('chat-tool-carousel-allow-all-button'); this.allowAllButton.label = localize('allowAll', "Allow All"); this._register(this.allowAllButton.onDidClick(() => this.allowAll())); + this.expandContentButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + this.expandContentButton.element.classList.add('chat-tool-carousel-header-button', 'chat-tool-carousel-expand-content-button'); + this.expandContentButton.element.setAttribute('aria-controls', this.contentContainer.id); + this.updateExpandContentButton(); + dom.hide(this.expandContentButton.element); + this._register(this.expandContentButton.onDidClick(() => this.toggleContentExpanded())); + this.dismissButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true })); this.dismissButton.element.classList.add('chat-tool-carousel-dismiss-button'); this.dismissButton.label = `$(${Codicon.close.id})`; @@ -143,7 +163,7 @@ export class ChatToolConfirmationCarouselPart extends Disposable { setMaxHeight(maxHeight: number | undefined): void { this.maxHeight = maxHeight; - this.updateMaxHeightStyle(); + this.updateContentExpansionState(); } hasToolInvocation(toolCallId: string): boolean { @@ -366,18 +386,23 @@ export class ChatToolConfirmationCarouselPart extends Disposable { dom.setVisibility(multi, this.prevButton.element); dom.setVisibility(multi, this.nextButton.element); dom.setVisibility(multi, this.allowAllButton.element); + dom.setVisibility(this.canExpandContent, this.expandContentButton.element); this.allowAllButton.label = multi ? localize('allowAll', "Allow All") : localize('allow', "Allow"); + this.updateExpandContentButton(); } private renderActiveContent(): void { dom.clearNode(this.contentContainer); this.activeContentDisposables.clear(); + this._isContentExpanded = false; + this.canExpandContent = false; const item = this.items[this.activeIndex]; if (!item) { + this.updateContentExpansionState(); return; } @@ -389,6 +414,30 @@ export class ChatToolConfirmationCarouselPart extends Disposable { } this.contentContainer.appendChild(item.toolPart.domNode); + this.activeContentDisposables.add(this.contentResizeObserver.observe(item.toolPart.domNode)); + this.observeExpandableContentElements(item.toolPart.domNode); + this.updateContentExpansionStateScheduler.schedule(); + } + + private toggleContentExpanded(): void { + if (!this.canExpandContent) { + return; + } + + this._isContentExpanded = !this._isContentExpanded; + this.updateContentExpansionState(); + } + + private updateContentExpansionState(): void { + this.canExpandContent = this.items.length > 0 && this.isActiveContentLargerThanCollapsedLimit(); + if (!this.canExpandContent) { + this._isContentExpanded = false; + } + + this.domNode.classList.toggle('chat-tool-carousel-content-expanded', this.canExpandContent && this._isContentExpanded); + this.updateMaxHeightStyle(); + dom.setVisibility(this.canExpandContent, this.expandContentButton.element); + this.updateExpandContentButton(); } private updateMaxHeightStyle(): void { @@ -397,10 +446,80 @@ export class ChatToolConfirmationCarouselPart extends Disposable { return; } - const maxHeight = this.getCollapsedMaxHeight(); + const expanded = this.canExpandContent && this._isContentExpanded; + const maxHeight = expanded ? Math.max(MIN_CAROUSEL_MAX_HEIGHT, this.maxHeight) : this.getCollapsedMaxHeight(); this.domNode.style.maxHeight = `${Math.floor(maxHeight)}px`; } + private updateExpandContentButton(): void { + const expanded = this.canExpandContent && this._isContentExpanded; + const label = expanded + ? localize('restoreConfirmationSize', "Restore Confirmation Size") + : localize('expandConfirmationUp', "Expand Confirmation Up"); + this.expandContentButton.label = expanded + ? `$(${Codicon.screenNormal.id})` + : `$(${Codicon.screenFull.id})`; + this.expandContentButton.element.setAttribute('aria-label', label); + this.expandContentButton.element.setAttribute('aria-expanded', String(expanded)); + this.expandContentButton.setTitle(label); + } + + private isActiveContentLargerThanCollapsedLimit(): boolean { + const activeContent = this.contentContainer.firstElementChild; + if (!dom.isHTMLElement(activeContent)) { + return false; + } + + return this.hasInnerContentLargerThanCollapsedLimit(activeContent); + } + + private hasInnerContentLargerThanCollapsedLimit(element: HTMLElement): boolean { + if (this.isExpandableContentElement(element) && this.getElementHeight(element) > this.getExpandableContentHeightLimit(element) + 1) { + return true; + } + + for (const child of element.children) { + if (!dom.isHTMLElement(child)) { + continue; + } + + if (this.hasInnerContentLargerThanCollapsedLimit(child)) { + return true; + } + } + + return false; + } + + private isExpandableContentElement(element: HTMLElement): boolean { + return element.matches(EXPANDABLE_CONTENT_SELECTOR); + } + + private observeExpandableContentElements(element: HTMLElement): void { + if (this.isExpandableContentElement(element)) { + this.activeContentDisposables.add(this.contentResizeObserver.observe(element)); + } + + for (const child of element.children) { + if (dom.isHTMLElement(child)) { + this.observeExpandableContentElements(child); + } + } + } + + private getElementHeight(element: HTMLElement): number { + return Math.max(element.offsetHeight, element.scrollHeight); + } + + private getExpandableContentHeightLimit(element: HTMLElement): number { + const window = dom.getWindow(this.domNode); + if (element.classList.contains('interactive-result-editor')) { + return Math.min(COLLAPSED_CODE_BLOCK_MAX_HEIGHT, window.innerHeight * 0.25); + } + + return Math.min(COLLAPSED_MESSAGE_MAX_HEIGHT, window.innerHeight * 0.3); + } + private getCollapsedMaxHeight(): number { const configuredMaxHeight = this.maxHeight === undefined ? Number.POSITIVE_INFINITY : Math.max(MIN_CAROUSEL_MAX_HEIGHT, this.maxHeight); return Math.min(configuredMaxHeight, COLLAPSED_CAROUSEL_MAX_HEIGHT, dom.getWindow(this.domNode).innerHeight * 0.45); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 4a1c20895e239..2839eba5c83bf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2195,6 +2195,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(); for (const optionGroup of allOptionGroups) { + if (optionGroup.kind === 'permissions') { + continue; + } const hasItems = optionGroup.items.length > 0 || (optionGroup.commands || []).length > 0; const passesWhenClause = this.evaluateOptionGroupVisibility(optionGroup); @@ -1746,6 +1752,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return Array.from(visibleGroups.values()); } + /** + * Returns the permissions-kind option group contributed by the active session provider, if any. + * Items from this group are surfaced inside the chat permission picker, replacing the + * built-in `ChatPermissionLevel` items. Honors the same visibility predicates as + * {@link getVisibleOptionGroups} so that `when` clauses are respected. + * + * If the provider declares more than one permissions-kind group (which the API forbids), + * the first one wins. + */ + private getActiveExtensionPermissionGroup(sessionResource: URI | undefined): IChatSessionProviderOptionGroup | undefined { + const allOptionGroups = this.getAllOptionsGroups(sessionResource); + return allOptionGroups.find(g => + g.kind === 'permissions' + && g.items.length > 0 + && this.evaluateOptionGroupVisibility(g) + ); + } + /** * Refresh all registered option groups for the current chat session. * Fires events for each option group with their current selection. @@ -2469,8 +2493,43 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge setPermissionLevel: (level: ChatPermissionLevel) => { this.setPermissionLevel(level); }, + getExtensionPermissions: () => { + const sessionResource = this.getCurrentSessionResource(); + const group = this.getActiveExtensionPermissionGroup(sessionResource); + if (!group) { + return undefined; + } + const current = sessionResource ? this.chatSessionsService.getSessionOption(sessionResource, group.id) : undefined; + const defaultId = group.selected?.id ?? group.items.find(i => i.default)?.id; + const rawSelectedId = current === undefined + ? defaultId + : typeof current === 'string' ? current : current.id; + const selectedId = rawSelectedId !== undefined && group.items.some(i => i.id === rawSelectedId) + ? rawSelectedId + : defaultId; + const sessionType = sessionResource + ? getChatSessionType(sessionResource) + : (this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() ?? ''); + return { sessionType, groupId: group.id, items: group.items, selectedId }; + }, + setExtensionPermission: (groupId: string, item: IChatSessionProviderOptionItem) => { + this.updateOptionContextKey(groupId, item.id); + this.getOrCreateOptionEmitter(groupId).fire(item); + const sessionResource = this.getCurrentSessionResource(); + if (sessionResource) { + this.chatSessionsService.setSessionOption(sessionResource, groupId, item); + } + this.permissionWidget?.refresh(); + }, }; - return this.permissionWidget = this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, pickerOptions); + const widget = this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, pickerOptions); + this.permissionWidget = widget; + widget.onDidDispose(() => { + if (this.permissionWidget === widget) { + this.permissionWidget = undefined; + } + }); + return widget; } return undefined; } 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 8ca3c47be607e..282a93c0d58c9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -355,7 +355,7 @@ export function buildModelPickerItems( if (item.kind === 'available') { items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect, languageModelsService!), item.model)); } else { - items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType)); + items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, chatEntitlementService)); } } } @@ -404,7 +404,7 @@ export function buildModelPickerItems( for (const model of otherModels) { const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other)); + items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, chatEntitlementService, ModelPickerSection.Other)); } else { items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, languageModelsService!, ModelPickerSection.Other), model)); } @@ -472,6 +472,7 @@ function createUnavailableModelItem( reason: 'upgrade' | 'update' | 'admin', manageSettingsUrl: string | undefined, updateStateType: StateType, + chatEntitlementService: IChatEntitlementService, section?: string, ): IActionListItem { let description: string | MarkdownString | undefined; @@ -489,7 +490,11 @@ function createUnavailableModelItem( let hoverContent: MarkdownString; if (reason === 'upgrade') { hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); + if (chatEntitlementService.entitlement === ChatEntitlement.Pro) { + hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHoverProPlus', "[Upgrade to GitHub Copilot Pro+](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); + } else { + hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); + } } else if (reason === 'update') { hoverContent = getUpdateHoverContent(updateStateType); } else { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index e5dd82824fa3e..301f601eb77b4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -6,6 +6,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -16,6 +17,7 @@ import { IContextKeyService } from '../../../../../../platform/contextkey/common import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; +import { IChatSessionProviderOptionItem } from '../../../common/chatSessionsService.js'; import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; @@ -52,12 +54,35 @@ function hasShownElevatedWarning(level: ChatPermissionLevel, storageService: ISt return false; } +export interface IExtensionPermissionState { + /** Stable identifier for the contributing chat session type, used to namespace action ids. */ + readonly sessionType: string; + readonly groupId: string; + readonly items: readonly IChatSessionProviderOptionItem[]; + readonly selectedId: string | undefined; +} + export interface IPermissionPickerDelegate { readonly currentPermissionLevel: IObservable; readonly setPermissionLevel: (level: ChatPermissionLevel) => void; + /** + * When defined and returns a non-empty state, the picker shows the extension-contributed + * items in place of the built-in {@link ChatPermissionLevel} items. + */ + readonly getExtensionPermissions?: () => IExtensionPermissionState | undefined; + readonly setExtensionPermission?: (groupId: string, item: IChatSessionProviderOptionItem) => void; +} + +/** Sanitize a free-form id segment so it is safe to embed in a stable action identifier. */ +function sanitizeIdSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, '_'); } export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { + + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose: Event = this._onDidDispose.event; + constructor( action: MenuItemAction, private readonly delegate: IPermissionPickerDelegate, @@ -75,6 +100,30 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { const isAutopilotEnabled = () => configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { + // If the active session contributes its own permission items, surface those instead + // of the built-in Default/AutoApprove/Autopilot levels. + const ext = delegate.getExtensionPermissions?.(); + if (ext && ext.items.length > 0) { + const sessionTypeSeg = sanitizeIdSegment(ext.sessionType); + const groupSeg = sanitizeIdSegment(ext.groupId); + return ext.items.map(item => ({ + ...action, + id: `chat.permissions.ext.${sessionTypeSeg}.${groupSeg}.${sanitizeIdSegment(item.id)}`, + label: item.name, + description: item.description, + icon: item.icon, + checked: ext.selectedId === item.id, + enabled: !item.locked, + tooltip: item.locked ? localize('permissions.ext.locked', "This option is locked") : '', + hover: item.description ? { content: item.description } : undefined, + run: async () => { + delegate.setExtensionPermission?.(ext.groupId, item); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction)); + } const currentLevel = delegate.currentPermissionLevel.get(); const policyRestricted = isAutoApprovePolicyRestricted(); const actions: IActionWidgetDropdownAction[] = [ @@ -237,22 +286,31 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { protected override renderLabel(element: HTMLElement): IDisposable | null { this.setAriaLabelAttributes(element); - const level = this.delegate.currentPermissionLevel.get(); + const ext = this.delegate.getExtensionPermissions?.(); let icon: ThemeIcon; let label: string; - switch (level) { - case ChatPermissionLevel.Autopilot: - icon = Codicon.rocket; - label = localize('permissions.autopilot.label', "Autopilot (Preview)"); - break; - case ChatPermissionLevel.AutoApprove: - icon = Codicon.warning; - label = localize('permissions.autoApprove.label', "Bypass Approvals"); - break; - default: - icon = Codicon.shield; - label = localize('permissions.default.label', "Default Approvals"); - break; + const level = this.delegate.currentPermissionLevel.get(); + if (ext && ext.items.length > 0) { + const selected = ext.items.find(i => i.id === ext.selectedId) + ?? ext.items.find(i => i.default) + ?? ext.items[0]; + icon = selected.icon ?? Codicon.lock; + label = selected.name; + } else { + switch (level) { + case ChatPermissionLevel.Autopilot: + icon = Codicon.rocket; + label = localize('permissions.autopilot.label', "Autopilot (Preview)"); + break; + case ChatPermissionLevel.AutoApprove: + icon = Codicon.warning; + label = localize('permissions.autoApprove.label', "Bypass Approvals"); + break; + default: + icon = Codicon.shield; + label = localize('permissions.default.label', "Default Approvals"); + break; + } } const labelElements = []; @@ -261,8 +319,10 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); dom.reset(element, ...labelElements); - element.classList.toggle('warning', level === ChatPermissionLevel.Autopilot); - element.classList.toggle('info', level === ChatPermissionLevel.AutoApprove); + element.classList.toggle('warning', !ext && level === ChatPermissionLevel.Autopilot); + element.classList.toggle('info', !ext && level === ChatPermissionLevel.AutoApprove); + + element.setAttribute('aria-label', localize('permissions.ariaLabel', "Permission picker, {0}", label)); return null; } @@ -271,4 +331,12 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { this.renderLabel(this.element); } } + + override dispose(): void { + if (this._store.isDisposed) { + return; + } + this._onDidDispose.fire(); + super.dispose(); + } } 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 0b419e07c820e..50e0163d2e50e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -930,8 +930,11 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-workbench .interactive-session .chat-input-container::before { content: ''; position: absolute; - inset: 0; - border-radius: inherit; + /* Pull the ring out by 1px so its outer edge aligns with where the + `box-shadow` glow on the container begins (the outer border-edge), + eliminating a sub-pixel gap between the rotating ring and the halo. */ + inset: -1px; + border-radius: calc(var(--vscode-cornerRadius-large) - 1px); padding: 1px; background: conic-gradient(from var(--chat-input-anim-angle), var(--vscode-chat-inputWorkingBorderColor1), diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 4b28454d5626f..c1bb2ebe4c4ee 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -289,6 +289,11 @@ export interface IChatWarningMessage { kind: 'warning'; } +export interface IChatInfoMessage { + content: IMarkdownString; + kind: 'info'; +} + export interface IChatAgentVulnerabilityDetails { title: string; description: string; @@ -1105,6 +1110,7 @@ export type IChatProgress = | IChatTaskResult | IChatCommandButton | IChatWarningMessage + | IChatInfoMessage | IChatTextEdit | IChatNotebookEdit | IChatWorkspaceEdit diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 61637cabe6709..66957b66c5d3e 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -38,6 +38,7 @@ export interface IChatSessionProviderOptionItem { readonly locked?: boolean; readonly icon?: ThemeIcon; readonly default?: boolean; + readonly slashCommand?: string; // [key: string]: any; } @@ -68,6 +69,15 @@ export interface IChatSessionProviderOptionGroup { * These will be shown in a separate section at the end of the picker. */ readonly commands?: readonly IChatSessionProviderOptionGroupCommand[]; + /** + * Optional kind hint that controls how the group is presented. + * - `'permissions'`: the group's items are surfaced inside the chat permission picker + * instead of being rendered as a standalone picker. At most one group per provider + * may use this kind; if multiple are declared, the first one (in declaration order) + * wins. The group has no UI of its own — it is invisible when the permission + * picker is hidden by its own `when` clauses. + */ + readonly kind?: 'permissions'; } export interface IChatSessionsExtensionPoint { diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index b1d6b59ad2b8d..ab4d6563037c6 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -30,7 +30,7 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; import { ChatPerfMark, markChat } from '../chatPerf.js'; -import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatPlanReview, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatPlanReview, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ToolDataSource, IToolData } from '../tools/languageModelToolsService.js'; @@ -189,6 +189,7 @@ export type IChatProgressHistoryResponseContent = | IChatProgressMessage | IChatCommandButton | IChatWarningMessage + | IChatInfoMessage | IChatTask | IChatTaskSerialized | IChatTextEditGroup @@ -637,6 +638,7 @@ class AbstractResponse implements IResponse { case 'progressTask': case 'progressTaskSerialized': case 'warning': + case 'info': segment = { text: part.content.value }; break; default: diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 3d1cc61619e38..7b5cfa0b73879 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -81,6 +81,7 @@ const responsePartSchema = Adapt.v'); entries.push('Here is a list of instruction files that contain rules for working with this codebase.'); entries.push('These files are important for understanding the codebase structure, conventions, and best practices.'); - entries.push('Please make sure to follow the rules specified in these files when working with the codebase.'); - entries.push(`If the file is not already available as attachment, use the ${readTool.variable} tool to acquire it.`); - entries.push('Make sure to acquire the instructions before working with the codebase.'); + entries.push('When an instruction file applies to your task (based on its description or applyTo pattern), follow the rules specified in it.'); + entries.push(`If the file content is not already included in the context, use the ${readTool.variable} tool to read it before proceeding. Use the exact value from the element as-is with the tool; do not add or remove prefixes or otherwise modify it.`); + entries.push('Only load instruction files when they are relevant to the current task. Do not eagerly load all instructions upfront.'); + entries.push('When modifying or creating files, check for instructions whose applyTo pattern matches the file path and follow them.'); let hasContent = false; for (const instruction of instructionFiles) { if (!matchesSessionType(instruction.sessionTypes, currentSessionType)) { continue; } entries.push(''); + entries.push(`${filePath(instruction.uri)}`); if (instruction.description) { entries.push(`${instruction.description}`); } - entries.push(`${filePath(instruction.uri)}`); if (instruction.pattern) { entries.push(`${instruction.pattern}`); } @@ -382,8 +383,8 @@ export class ComputeAutomaticInstructions { const folderName = this._labelService.getUriLabel(dirname(uri), { relative: true }); const description = folderName.trim().length === 0 ? localize('instruction.file.description.agentsmd.root', 'Instructions for the workspace') : localize('instruction.file.description.agentsmd.folder', 'Instructions for folder \'{0}\'', folderName); entries.push(''); - entries.push(`${description}`); entries.push(`${filePath(uri)}`); + entries.push(`${description}`); entries.push(''); hasContent = true; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 2c4dc344c1dfd..bc793a2aa2c78 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -27,6 +27,7 @@ import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; import { equalsIgnoreCase } from '../../../../../../base/common/strings.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { AGENT_HOST_SCHEME } from '../../../../../../platform/agentHost/common/agentHostUri.js'; /** * Maximum recursion depth when traversing subdirectories for instruction files. @@ -70,7 +71,12 @@ export class PromptFilesLocator { } protected getWorkspaceFolders(): readonly IWorkspaceFolder[] { - return this.workspaceService.getWorkspace().folders; + // Agent host workspace folders surface customizations through AHP + // (session state + findAgentSkills), not via filesystem scanning. + // Including them here would issue a `resourceList` JSON-RPC per + // configured location for every nonexistent `.github` / `.claude` + // folder on the remote. + return this.workspaceService.getWorkspace().folders.filter(f => f.uri.scheme !== AGENT_HOST_SCHEME); } protected getWorkspaceFolder(resource: URI): IWorkspaceFolder | undefined { diff --git a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts index 7b56d9af30699..411ea7e704ad2 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts @@ -90,15 +90,15 @@ export const chatThinkingShimmer = registerColor( export const chatInputWorkingBorderColor1 = registerColor( 'chat.inputWorkingBorderColor1', - { dark: '#b44aff', light: '#b44aff', hcDark: '#b44aff', hcLight: '#b44aff' }, + { dark: '#E8E8EC', light: '#B8B8C0', hcDark: '#FFFFFF', hcLight: '#000000' }, localize('chat.inputWorkingBorderColor1', 'First color stop of the animated chat input border shown while a request is in flight.'), true); export const chatInputWorkingBorderColor2 = registerColor( 'chat.inputWorkingBorderColor2', - { dark: '#4af0c0', light: '#4af0c0', hcDark: '#4af0c0', hcLight: '#4af0c0' }, + { dark: '#8A8A92', light: '#7A7A82', hcDark: '#A0A0A0', hcLight: '#555555' }, localize('chat.inputWorkingBorderColor2', 'Second color stop of the animated chat input border shown while a request is in flight.'), true); export const chatInputWorkingBorderColor3 = registerColor( 'chat.inputWorkingBorderColor3', - { dark: '#51a2ff', light: '#51a2ff', hcDark: '#51a2ff', hcLight: '#51a2ff' }, + { dark: '#3A3A40', light: '#2E2E34', hcDark: '#000000', hcLight: '#000000' }, localize('chat.inputWorkingBorderColor3', 'Third color stop of the animated chat input border shown while a request is in flight.'), true); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index e8d0902d1404e..1f1306918de89 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -2782,6 +2782,32 @@ suite('PromptFilesLocator', () => { ); }); + testT('excludes vscode-agent-host workspace folders', async () => { + // Agent host folders surface customizations through AHP, not via + // filesystem scanning. Including them here would issue a `resourceList` + // JSON-RPC per configured location for every nonexistent `.github` / + // `.claude` folder on the remote. + const localFolder = URI.file('/repos/local-project'); + const agentHostFolder = URI.from({ scheme: 'vscode-agent-host', authority: 'remote', path: '/repos/remote-project' }); + const folders = [localFolder, agentHostFolder].map((uri, index) => new class extends mock() { + override uri = uri; + override name = basename(uri); + override index = index; + }); + instantiationService.stub(IWorkspaceContextService, mockWorkspaceService(folders)); + locator = instantiationService.createInstance(PromptFilesLocator); + await mockFiles(fileService, [ + { path: '/repos/local-project/.git/HEAD', contents: ['ref: refs/heads/main'] }, + ]); + + const roots = await locator.getWorkspaceFolderRoots(true); + assert.deepStrictEqual( + roots.map(r => r.toString()), + [localFolder.toString()], + 'Should exclude vscode-agent-host workspace folders from prompt-file discovery roots', + ); + }); + testT('returns only workspace folder when no .git is found', async () => { setWorkspaceFoldersForRoots(['/Users/legomushroom/my-project']); await mockFiles(fileService, [ diff --git a/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts b/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts index 1028ec8992eb8..ec77d2fd31a7b 100644 --- a/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts +++ b/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts @@ -16,7 +16,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { selectBorder, selectBackground, asCssVariable } from '../../../../platform/theme/common/colorRegistry.js'; import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; -import { IDisposable, dispose } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, dispose } from '../../../../base/common/lifecycle.js'; import { ADD_CONFIGURATION_ID } from './debugCommands.js'; import { BaseActionViewItem, IBaseActionViewItemOptions, SelectActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { debugStart } from './debugIcons.js'; @@ -309,18 +309,18 @@ export class FocusSessionActionViewItem extends SelectActionViewItem { + const sessionListeners = sessionListenersStore.add(new DisposableStore()); + sessionListeners.add(session.onDidChangeName(() => this.update())); + sessionListeners.add(session.onDidEndAdapter(() => sessionListenersStore.delete(sessionListeners))); + }; this._register(this.debugService.onDidNewSession(session => { - const sessionListeners: IDisposable[] = []; - sessionListeners.push(session.onDidChangeName(() => this.update())); - sessionListeners.push(session.onDidEndAdapter(() => dispose(sessionListeners))); + registerSessionListeners(session); this.update(); })); // Apply the same pattern to existing sessions - track listeners for cleanup - this.getSessions().forEach(session => { - const sessionListeners: IDisposable[] = []; - sessionListeners.push(session.onDidChangeName(() => this.update())); - sessionListeners.push(session.onDidEndAdapter(() => dispose(sessionListeners))); - }); + this.getSessions().forEach(registerSessionListeners); this._register(this.debugService.onDidEndSession(() => this.update())); const selectedSession = session ? this.mapFocusedSessionToSelected(session) : undefined; diff --git a/src/vs/workbench/contrib/debug/browser/disassemblyView.ts b/src/vs/workbench/contrib/debug/browser/disassemblyView.ts index 42d6a0bd7c49f..9d2e8180f631f 100644 --- a/src/vs/workbench/contrib/debug/browser/disassemblyView.ts +++ b/src/vs/workbench/contrib/debug/browser/disassemblyView.ts @@ -116,7 +116,7 @@ export class DisassemblyView extends EditorPane { this.menu = menuService.createMenu(MenuId.DebugDisassemblyContext, contextKeyService); this._register(this.menu); this._disassembledInstructions = undefined; - this._onDidChangeStackFrame = this._register(new Emitter({ leakWarningThreshold: 1000 })); + this._onDidChangeStackFrame = this._register(new Emitter({ leakWarningThreshold: 1000, leakWarningName: 'DisassemblyView._onDidChangeStackFrame' })); this._previousDebuggingState = _debugService.state; this._register(_configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('debug')) { diff --git a/src/vs/workbench/contrib/scm/browser/quickDiff.contribution.ts b/src/vs/workbench/contrib/scm/browser/quickDiff.contribution.ts new file mode 100644 index 0000000000000..0f64f59872000 --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/quickDiff.contribution.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../common/contributions.js'; +import { localize } from '../../../../nls.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; +import { IQuickDiffService } from '../common/quickDiff.js'; +import { QuickDiffService } from '../common/quickDiffService.js'; +import { QuickDiffWorkbenchController } from './quickDiffDecorator.js'; +import { IQuickDiffModelService, QuickDiffModelService } from './quickDiffModel.js'; +import { QuickDiffEditorController } from './quickDiffWidget.js'; + +MenuRegistry.appendMenuItem(MenuId.EditorLineNumberContext, { + title: localize('quickDiffDecoration', "Diff Decorations"), + submenu: MenuId.SCMQuickDiffDecorations, + when: ContextKeyExpr.or( + ContextKeyExpr.equals('config.scm.diffDecorations', 'all'), + ContextKeyExpr.equals('config.scm.diffDecorations', 'gutter')), + group: '9_quickDiffDecorations' +}); + +Registry.as(WorkbenchExtensions.Workbench) + .registerWorkbenchContribution(QuickDiffWorkbenchController, LifecyclePhase.Restored); + +registerEditorContribution(QuickDiffEditorController.ID, + QuickDiffEditorController, EditorContributionInstantiation.AfterFirstRender); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'scm', + order: 5, + title: localize('scmConfigurationTitle', "Source Control"), + type: 'object', + scope: ConfigurationScope.RESOURCE, + properties: { + 'scm.diffDecorations': { + type: 'string', + enum: ['all', 'gutter', 'overview', 'minimap', 'none'], + enumDescriptions: [ + localize('scm.diffDecorations.all', "Show the diff decorations in all available locations."), + localize('scm.diffDecorations.gutter', "Show the diff decorations only in the editor gutter."), + localize('scm.diffDecorations.overviewRuler', "Show the diff decorations only in the overview ruler."), + localize('scm.diffDecorations.minimap', "Show the diff decorations only in the minimap."), + localize('scm.diffDecorations.none', "Do not show the diff decorations.") + ], + default: 'all', + description: localize('diffDecorations', "Controls diff decorations in the editor.") + }, + 'scm.diffDecorationsGutterWidth': { + type: 'number', + enum: [1, 2, 3, 4, 5], + default: 3, + description: localize('diffGutterWidth', "Controls the width(px) of diff decorations in gutter (added & modified).") + }, + 'scm.diffDecorationsGutterVisibility': { + type: 'string', + enum: ['always', 'hover'], + enumDescriptions: [ + localize('scm.diffDecorationsGutterVisibility.always', "Show the diff decorator in the gutter at all times."), + localize('scm.diffDecorationsGutterVisibility.hover', "Show the diff decorator in the gutter only on hover.") + ], + description: localize('scm.diffDecorationsGutterVisibility', "Controls the visibility of the Source Control diff decorator in the gutter."), + default: 'always' + }, + 'scm.diffDecorationsGutterAction': { + type: 'string', + enum: ['diff', 'none'], + enumDescriptions: [ + localize('scm.diffDecorationsGutterAction.diff', "Show the inline diff Peek view on click."), + localize('scm.diffDecorationsGutterAction.none', "Do nothing.") + ], + description: localize('scm.diffDecorationsGutterAction', "Controls the behavior of Source Control diff gutter decorations."), + default: 'diff' + }, + 'scm.diffDecorationsGutterPattern': { + type: 'object', + description: localize('diffGutterPattern', "Controls whether a pattern is used for the diff decorations in gutter."), + additionalProperties: false, + properties: { + 'added': { + type: 'boolean', + description: localize('diffGutterPatternAdded', "Use pattern for the diff decorations in gutter for added lines."), + }, + 'modified': { + type: 'boolean', + description: localize('diffGutterPatternModifed', "Use pattern for the diff decorations in gutter for modified lines."), + }, + }, + default: { + 'added': false, + 'modified': true + } + }, + 'scm.diffDecorationsIgnoreTrimWhitespace': { + type: 'string', + enum: ['true', 'false', 'inherit'], + enumDescriptions: [ + localize('scm.diffDecorationsIgnoreTrimWhitespace.true', "Ignore leading and trailing whitespace."), + localize('scm.diffDecorationsIgnoreTrimWhitespace.false', "Do not ignore leading and trailing whitespace."), + localize('scm.diffDecorationsIgnoreTrimWhitespace.inherit', "Inherit from `diffEditor.ignoreTrimWhitespace`.") + ], + description: localize('diffDecorationsIgnoreTrimWhitespace', "Controls whether leading and trailing whitespace is ignored in Source Control diff gutter decorations."), + default: 'false' + } + } +}); + +registerSingleton(IQuickDiffService, QuickDiffService, InstantiationType.Delayed); +registerSingleton(IQuickDiffModelService, QuickDiffModelService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 6bdf7f369a1b2..6ee2a32c3a8de 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -6,7 +6,6 @@ import { localize, localize2 } from '../../../../nls.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContributionsRegistry, registerWorkbenchContribution2, Extensions as WorkbenchExtensions, WorkbenchPhase } from '../../../common/contributions.js'; -import { QuickDiffWorkbenchController } from './quickDiffDecorator.js'; import { VIEWLET_ID, ISCMService, VIEW_PANE_ID, ISCMProvider, ISCMViewService, REPOSITORIES_VIEW_PANE_ID, HISTORY_VIEW_PANE_ID } from '../common/scm.js'; import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { MenuRegistry, MenuId, registerAction2, Action2 } from '../../../../platform/actions/common/actions.js'; @@ -16,8 +15,6 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions, Configur import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { SCMService } from '../common/scmService.js'; import { IViewContainersRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, IViewsRegistry } from '../../../common/views.js'; import { SCMViewPaneContainer } from './scmViewPaneContainer.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; @@ -25,23 +22,18 @@ import { ModesRegistry } from '../../../../editor/common/languages/modesRegistry import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ContextKeys, SCMViewPane } from './scmViewPane.js'; -import { RepositoryPicker, SCMViewService } from './scmViewService.js'; +import { RepositoryPicker } from './scmViewService.js'; import { SCMRepositoriesViewPane } from './scmRepositoriesViewPane.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Context as SuggestContext } from '../../../../editor/contrib/suggest/browser/suggest.js'; import { InlineCompletionContextKeys } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionContextKeys.js'; import { MANAGE_TRUST_COMMAND_ID, WorkspaceTrustContext } from '../../workspace/common/workspace.js'; -import { IQuickDiffService } from '../common/quickDiff.js'; -import { QuickDiffService } from '../common/quickDiffService.js'; import { getActiveElement, isActiveElement } from '../../../../base/browser/dom.js'; import { SCMWorkingSetController } from './workingSet.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IListService, WorkbenchList } from '../../../../platform/list/browser/listService.js'; import { isSCMRepository } from './util.js'; import { SCMHistoryViewPane } from './scmHistoryViewPane.js'; -import { QuickDiffModelService, IQuickDiffModelService } from './quickDiffModel.js'; -import { QuickDiffEditorController } from './quickDiffWidget.js'; -import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; import { RemoteNameContext, ResourceContextKey } from '../../../common/contextkeys.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { SCMAccessibilityHelp } from './scmAccessibilityHelp.js'; @@ -59,12 +51,6 @@ ModesRegistry.registerLanguage({ mimetypes: ['text/x-scm-input'] }); -Registry.as(WorkbenchExtensions.Workbench) - .registerWorkbenchContribution(QuickDiffWorkbenchController, LifecyclePhase.Restored); - -registerEditorContribution(QuickDiffEditorController.ID, - QuickDiffEditorController, EditorContributionInstantiation.AfterFirstRender); - const sourceControlViewIcon = registerIcon('source-control-view-icon', Codicon.sourceControl, localize('sourceControlViewIcon', 'View icon of the Source Control view.')); const viewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ @@ -682,15 +668,6 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ } }); -MenuRegistry.appendMenuItem(MenuId.EditorLineNumberContext, { - title: localize('quickDiffDecoration', "Diff Decorations"), - submenu: MenuId.SCMQuickDiffDecorations, - when: ContextKeyExpr.or( - ContextKeyExpr.equals('config.scm.diffDecorations', 'all'), - ContextKeyExpr.equals('config.scm.diffDecorations', 'gutter')), - group: '9_quickDiffDecorations' -}); - registerAction2(class extends Action2 { constructor() { super({ @@ -728,10 +705,4 @@ registerAction2(class extends Action2 { } }); - -registerSingleton(ISCMService, SCMService, InstantiationType.Delayed); -registerSingleton(ISCMViewService, SCMViewService, InstantiationType.Delayed); -registerSingleton(IQuickDiffService, QuickDiffService, InstantiationType.Delayed); -registerSingleton(IQuickDiffModelService, QuickDiffModelService, InstantiationType.Delayed); - AccessibleViewRegistry.register(new SCMAccessibilityHelp()); diff --git a/src/vs/workbench/contrib/scm/browser/scm.service.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.service.contribution.ts new file mode 100644 index 0000000000000..9f1b8728dc81e --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/scm.service.contribution.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ISCMService, ISCMViewService } from '../common/scm.js'; +import { SCMService } from '../common/scmService.js'; +import { SCMViewService } from './scmViewService.js'; + +registerSingleton(ISCMService, SCMService, InstantiationType.Delayed); +registerSingleton(ISCMViewService, SCMViewService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 45061dccdb093..19a9fba7ab215 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { SequencerByKey } from '../../../../base/common/async.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -103,6 +104,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe /** Revived terminal instances, keyed by terminal URI string. */ private readonly _revivedInstances = new Map(); + private readonly _reviveSequencer = new SequencerByKey(); constructor( @ITerminalService private readonly _terminalService: ITerminalService, @@ -300,11 +302,14 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe async reviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): Promise { const key = terminalUri.toString(); + return this._reviveSequencer.queue(key, () => this._doReviveTerminal(connection, terminalUri, terminalToolSessionId, key)); + } + + private async _doReviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string, key: string): Promise { const existing = this._revivedInstances.get(key); if (existing) { return existing; } - const store = new DisposableStore(); const commandSource = store.add(new AhpTerminalCommandSource()); store.add(this._terminalChatService.registerAhpCommandSource(terminalToolSessionId, commandSource)); @@ -327,6 +332,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe }, name: localize('agentHostTerminal.tool', "Agent Host Terminal"), isFeatureTerminal: true, + hideFromUser: true, }, }); this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, instance); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts index ad8da17c7d126..2f8cf0ba38103 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts @@ -7,9 +7,7 @@ import type { CancellationToken } from '../../../../../../base/common/cancellati import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; -import { ITerminalService } from '../../../../terminal/browser/terminal.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; -import { getOutput } from '../outputHelpers.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; import { TerminalToolId } from './toolIds.js'; @@ -18,7 +16,7 @@ export const GetTerminalOutputToolData: IToolData = { toolReferenceName: 'getTerminalOutput', legacyToolReferenceFullNames: ['runCommands/getTerminalOutput'], displayName: localize('getTerminalOutputTool.displayName', 'Get Terminal Output'), - modelDescription: `Get output from a terminal session. This can target either a persistent terminal started with ${TerminalToolId.RunInTerminal} in async mode (using 'id') or any foreground terminal visible in the terminal panel (using 'terminalId'). For the 'id' parameter, this must be the exact opaque UUID returned by ${TerminalToolId.RunInTerminal}; terminal names, labels, and integers are not valid for 'id'.`, + modelDescription: `Get output from an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}).`, icon: Codicon.terminal, source: ToolDataSource.Internal, inputSchema: { @@ -26,27 +24,21 @@ export const GetTerminalOutputToolData: IToolData = { properties: { id: { type: 'string', - description: `The ID of a persistent terminal session to check (returned by ${TerminalToolId.RunInTerminal} in async mode). This must be the exact opaque ID returned by that tool; terminal names, labels, or integers are invalid. Provide either 'id' or 'terminalId', not both.`, + description: `The ID of an active terminal execution to check (returned by ${TerminalToolId.RunInTerminal} for async executions, or for sync executions that timed out and were moved to the background). This must be the exact opaque UUID returned by that tool; terminal names, labels, or integers are invalid.`, pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' }, - terminalId: { - type: 'number', - description: 'The numeric instanceId of a terminal. Use this to get output from terminals not started by the agent (e.g., user-created terminals or foreground terminals). Provide either \'id\' or \'terminalId\', not both.' - }, }, + required: ['id'], } }; export interface IGetTerminalOutputInputParams { id?: string; - terminalId?: number; } export class GetTerminalOutputTool extends Disposable implements IToolImpl { - constructor( - @ITerminalService private readonly _terminalService: ITerminalService, - ) { + constructor() { super(); } @@ -60,38 +52,16 @@ export class GetTerminalOutputTool extends Disposable implements IToolImpl { async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const args = invocation.parameters as IGetTerminalOutputInputParams; - if (!args.id && args.terminalId === undefined) { - return { - content: [{ - kind: 'text', - value: 'Error: Either \'id\' (persistent terminal UUID) or \'terminalId\' (foreground terminal instanceId) must be provided.' - }] - }; - } - - // Foreground terminal path — only when no persistent id is provided - if (args.terminalId !== undefined && !args.id) { - const instance = this._terminalService.getInstanceFromId(args.terminalId); - if (!instance) { - return { - content: [{ - kind: 'text', - value: `Error: No terminal found with instanceId ${args.terminalId}. The terminal may have been closed.` - }] - }; - } - - const output = getOutput(instance); + if (!args.id) { return { content: [{ kind: 'text', - value: `Output of terminal ${args.terminalId}:\n${output}` + value: `Error: 'id' (the persistent terminal UUID returned by ${TerminalToolId.RunInTerminal} in async mode) must be provided.` }] }; } - // Persistent (background) terminal path - const execution = RunInTerminalTool.getExecution(args.id!); + const execution = RunInTerminalTool.getExecution(args.id); if (!execution) { return { content: [{ 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 f97e93314aae0..a9d271b53d714 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -473,12 +473,16 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { private readonly _terminalsBeingDisposedBySessionCleanup = new Set(); /** - * Tracks active background completion notifications per terminal ID. + * Tracks active background completion notifications per terminal instance ID. * When a new notification is registered for a terminal that already has one, * the previous notification (and its OutputMonitor) is disposed first to * prevent listener accumulation on the terminal's onDidInputData emitter. + * + * Keyed by `ITerminalInstance.instanceId` (stable per terminal) rather than + * the per-invocation `termId` so that reusing the same foreground terminal + * after an `inputNeeded` race disposes the prior OutputMonitor. */ - private readonly _backgroundNotifications = this._register(new DisposableMap()); + private readonly _backgroundNotifications = this._register(new DisposableMap()); // Immutable window state protected readonly _osBackend: Promise; @@ -1393,11 +1397,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText += `${outputAnalyzerMessage}\n`; } resultText += pollingResult.output; - if (isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService)) { - resultText += `\nIf the command is waiting for input (not a normal shell prompt), determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time.`; - } else { - resultText += `\nIf the command is waiting for input (not a normal shell prompt), call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each.`; - } + resultText += `\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}`; } else if (pollingResult) { resultText += `\n The command is still running, with output:\n`; if (outputAnalyzerMessage) { @@ -1703,21 +1703,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } - const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); if (didInputNeeded) { - if (isAutoApproved) { - resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. If it IS waiting for input (not a normal shell prompt), determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time.\n\n`); - } else { - resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. If it IS waiting for input (not a normal shell prompt), call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each.\n\n`); - } + resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications ? ' You will be automatically notified on your next turn when it completes.' : ''; - const inputAction = isAutoApproved - ? `If it IS waiting for input (not a normal shell prompt), determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time.` - : `If it IS waiting for input (not a normal shell prompt), call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each.`; - resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint} Use ${TerminalToolId.GetTerminalOutput} to check output, ${TerminalToolId.SendToTerminal} to send input, or ${TerminalToolId.KillTerminal} to stop it. ${inputAction}\n\n`); + resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ true)}\n\n`); } const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { @@ -1755,6 +1747,35 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + /** + * Builds the steering text the model sees when the terminal tool suspects + * the command may be waiting for input. The heuristic that triggers this + * note can false-positive on long-running compute commands or shells sitting + * on a secondary prompt (e.g. heredoc continuation `> `), so the text + * explicitly: + * 1. Tells the model this note is NOT a signal to end the turn. + * 2. Leads with `get_terminal_output` as the safe recovery action. + * 3. Offers `send_to_terminal` / `vscode_askQuestions` only for real prompts. + * `kill_terminal` is only advertised on the timeout branch — suggesting it + * in the general case leads the model to terminate valid interactive + * sessions (e.g. `npm init`) instead of driving them. + */ + private _buildInputNeededSteeringText(chatSessionResource: URI, termId: string, mentionTimeout: boolean): string { + const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); + const realInputBranch = isAutoApproved + ? `determine the answer and call ${TerminalToolId.SendToTerminal} with id="${termId}" (which returns the next few lines of output). Repeat one prompt at a time.` + : `call the vscode_askQuestions tool to ask the user, then send each answer using ${TerminalToolId.SendToTerminal} with id="${termId}" (which returns the next few lines of output). Repeat one prompt at a time.`; + const lines = [ + `This note is not a signal to end the turn — pick one of the actions below and continue.`, + ` 1. If the command may still be producing output or the shell prompt has not returned, call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. This is the default and safest action when unsure.`, + ` 2. Only if the output clearly ends with a real input prompt (password:, Continue? (y/n), etc. — a normal shell prompt like \`$\` or \`#\` does NOT count), ${realInputBranch}`, + ]; + if (mentionTimeout) { + lines.push(` 3. A timeout does not mean the command failed — call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. Only call ${TerminalToolId.KillTerminal} if the command is genuinely hung and you need to retry with a different approach.`); + } + return lines.join('\n'); + } + 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 }); @@ -2091,9 +2112,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { * The output monitor is cancelled and disposed when a command finishes. */ private _registerCompletionNotification(terminalInstance: ITerminalInstance, termId: string, chatSessionResource: URI, commandName: string, outputMonitor?: OutputMonitor): void { - // Dispose any previous background notification for this terminal to prevent - // listener accumulation (e.g. multiple onDidInputData subscriptions). - this._backgroundNotifications.deleteAndDispose(termId); + // Dispose any previous background notification for this terminal instance to prevent + // listener accumulation (e.g. multiple onDidInputData subscriptions) when the same + // foreground terminal is reused across run_in_terminal invocations. + const notificationKey = terminalInstance.instanceId; + this._backgroundNotifications.deleteAndDispose(notificationKey); const commandDetection = terminalInstance.capabilities.get(TerminalCapability.CommandDetection); if (!commandDetection) { @@ -2133,7 +2156,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // stops asking questions and lets the user finish interacting with the terminal. let userIsReplyingDirectly = false; - const disposeNotification = () => this._backgroundNotifications.deleteAndDispose(termId); + const disposeNotification = () => this._backgroundNotifications.deleteAndDispose(notificationKey); // If the user manually stopped the agent, suppress background // steering requests and tear down the notification listeners. @@ -2198,11 +2221,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } lastInputNeededOutput = currentOutput; lastInputNeededNotificationTime = now; - const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); - const inputAction = isAutoApproved - ? `Determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time. A normal shell prompt does NOT count as waiting for input.` - : `Call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each. A normal shell prompt does NOT count as waiting for input.`; - const message = `[Terminal ${termId} notification: command is waiting for input. ${inputAction}]\nTerminal output:\n${currentOutput}`; + const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false); + const message = `[Terminal ${termId} notification: command is waiting for input.]\n${inputAction}\nTerminal output:\n${currentOutput}`; this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`); @@ -2284,7 +2304,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } })); - this._backgroundNotifications.set(termId, store); + this._backgroundNotifications.set(notificationKey, store); } /** diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts index 513c0860ab64a..6cffa51d47d50 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts @@ -27,7 +27,7 @@ export const SendToTerminalToolData: IToolData = { id: TerminalToolId.SendToTerminal, toolReferenceName: 'sendToTerminal', displayName: localize('sendToTerminalTool.displayName', 'Send to Terminal'), - modelDescription: `Send input text to a terminal session. This can target either a persistent terminal started with ${TerminalToolId.RunInTerminal} in async mode (using 'id') or any foreground terminal visible in the terminal panel (using 'terminalId'). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). The result includes the last few lines of terminal output captured shortly after sending.`, + modelDescription: `Send input text to an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). The result includes the last few lines of terminal output captured shortly after sending.`, icon: Codicon.terminal, source: ToolDataSource.Internal, inputSchema: { @@ -35,27 +35,23 @@ export const SendToTerminalToolData: IToolData = { properties: { id: { type: 'string', - description: `The ID of a persistent terminal session to send a command to (returned by ${TerminalToolId.RunInTerminal} in async mode). Provide either 'id' or 'terminalId', not both.`, + description: `The ID of an active terminal execution to send a command to (returned by ${TerminalToolId.RunInTerminal} for async executions, or for sync executions that timed out and were moved to the background).`, pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' }, - terminalId: { - type: 'number', - description: 'The numeric instanceId of a terminal. Use this to send input to terminals not started by the agent (e.g., user-created terminals or terminals that need interactive input). Provide either \'id\' or \'terminalId\', not both.' - }, command: { type: 'string', description: 'The input text to send to the terminal. The text is sent followed by Enter. Provide an empty or whitespace string to send just Enter (for interactive prompts).' }, }, required: [ + 'id', 'command', ] } }; export interface ISendToTerminalInputParams { - id?: string; - terminalId?: number; + id: string; command: string; } @@ -88,7 +84,6 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { @IChatService private readonly _chatService: IChatService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, - @ITerminalService private readonly _terminalService: ITerminalService, ) { super(); } @@ -180,13 +175,7 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { return execution.instance.title; } } - if (args.terminalId !== undefined) { - const instance = this._terminalService.getInstanceFromId(args.terminalId); - if (instance) { - return instance.title; - } - } - return args.id ?? String(args.terminalId ?? ''); + return args.id ?? ''; } /** @@ -194,9 +183,6 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { * to build command URIs for the "Focus Terminal" link. */ private _getTerminalInstanceId(args: ISendToTerminalInputParams): number | undefined { - if (args.terminalId !== undefined) { - return args.terminalId; - } if (args.id) { const execution = RunInTerminalTool.getExecution(args.id); if (execution) { @@ -229,7 +215,7 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { } // Resolve the terminal ID that will match the carousel's terminalId - if (!args.id && args.terminalId === undefined) { + if (!args.id) { return undefined; } @@ -254,10 +240,7 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { if (!candidate.terminalId || candidate.questions.length === 0) { continue; } - const matchesById = !!args.id && candidate.terminalId === args.id; - const matchesByInstanceId = args.terminalId !== undefined && - RunInTerminalTool.getExecution(candidate.terminalId)?.instance.instanceId === args.terminalId; - if (matchesById || matchesByInstanceId) { + if (candidate.terminalId === args.id) { carouselIndex = j; carousel = candidate; break; @@ -306,10 +289,12 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { /** * Checks whether a carousel answer value matches the command text being sent. + * An empty/unprovided answer matches an empty command (i.e. pressing Enter to + * accept the default), since that is the expected way to skip a question. */ private _answerMatchesCommand(answer: IChatQuestionAnswerValue | undefined, commandText: string): boolean { if (answer === undefined) { - return false; + return commandText === ''; } if (typeof answer === 'string') { return answer.trim() === commandText; @@ -320,11 +305,17 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { if (multi.selectedValues.some(v => v.trim() === commandText)) { return true; } - return multi.freeformValue?.trim() === commandText; + if (multi.freeformValue?.trim() === commandText) { + return true; + } + return commandText === '' && multi.selectedValues.length === 0 && !multi.freeformValue?.trim(); } if (hasKey(answer, { selectedValue: true })) { const single = answer as IChatSingleSelectAnswer; - return single.selectedValue?.trim() === commandText || single.freeformValue?.trim() === commandText; + if (single.selectedValue?.trim() === commandText || single.freeformValue?.trim() === commandText) { + return true; + } + return commandText === '' && !single.selectedValue?.trim() && !single.freeformValue?.trim(); } return false; } @@ -332,42 +323,16 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { const args = invocation.parameters as ISendToTerminalInputParams; - if (!args.id && args.terminalId === undefined) { - return { - content: [{ - kind: 'text', - value: 'Error: Either \'id\' (persistent terminal UUID) or \'terminalId\' (foreground terminal instanceId) must be provided.' - }] - }; - } - - // Foreground terminal path — only when no persistent id is provided - if (args.terminalId !== undefined && !args.id) { - const instance = this._terminalService.getInstanceFromId(args.terminalId); - if (!instance) { - return { - content: [{ - kind: 'text', - value: `Error: No terminal found with instanceId ${args.terminalId}. The terminal may have been closed.` - }] - }; - } - - await instance.sendText(normalizeCommandForExecution(args.command), true); - - await timeout(100); - const recentOutput = getOutput(instance, undefined, { lastNLines: 5 }); - + if (!args.id) { return { content: [{ kind: 'text', - value: `Successfully sent command to foreground terminal ${args.terminalId}.${recentOutput ? `\n\nTerminal output (last 5 lines):\n${recentOutput}` : ''}` + value: `Error: 'id' (the active terminal execution UUID returned by ${TerminalToolId.RunInTerminal}) must be provided.` }] }; } - // Persistent (background) terminal path - const execution = RunInTerminalTool.getExecution(args.id!); + const execution = RunInTerminalTool.getExecution(args.id); if (!execution) { return { content: [{ diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts index ee964171443f5..722c0f534afe0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts @@ -56,14 +56,12 @@ suite('GetTerminalOutputTool', () => { }; } - test('tool description documents opaque terminal ids', () => { + test('tool schema requires a UUID id', () => { const idProperty = GetTerminalOutputToolData.inputSchema?.properties?.id as { description?: string; pattern?: string } | undefined; - assert.ok(GetTerminalOutputToolData.modelDescription.includes('exact opaque UUID')); - assert.ok(/exact opaque (uuid|id) returned by that tool/i.test(idProperty?.description ?? '')); assert.ok(idProperty?.pattern?.includes('[0-9a-fA-F]{8}')); }); - test('returns error when neither id nor terminalId is provided', async () => { + test('returns error when id is not provided', async () => { const result = await tool.invoke( { parameters: {}, callId: 'test-call', context: { sessionId: 'test-session' }, toolId: 'get_terminal_output', tokenBudget: 1000, isComplete: () => false, isCancellationRequested: false } as unknown as IToolInvocation, async () => 0, @@ -72,7 +70,7 @@ suite('GetTerminalOutputTool', () => { ); const value = (result.content[0] as { value: string }).value; - assert.ok(value.includes('Either')); + assert.ok(value.includes('must be provided')); }); test('returns explicit error for unknown terminal id', async () => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts index c97a2e32bad5b..85c22f38739a7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts @@ -71,12 +71,8 @@ suite('SendToTerminalTool', () => { }; } - test('tool description documents terminal IDs and use cases', () => { + test('tool schema requires a UUID id', () => { const idProperty = SendToTerminalToolData.inputSchema?.properties?.id as { description?: string; pattern?: string } | undefined; - const commandProperty = SendToTerminalToolData.inputSchema?.properties?.command as { description?: string } | undefined; - assert.ok(SendToTerminalToolData.modelDescription.includes('Send input text to a terminal session')); - assert.ok(SendToTerminalToolData.modelDescription.includes('may be empty or whitespace to press Enter')); - assert.ok(commandProperty?.description?.includes('Provide an empty or whitespace string to send just Enter')); assert.ok(idProperty?.pattern?.includes('[0-9a-fA-F]{8}')); }); diff --git a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts index 16ace8d597d8b..6a83d64d7186a 100644 --- a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts +++ b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts @@ -70,7 +70,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx private readonly _onDidChangeExtensionsStatus = this._register(new Emitter()); public readonly onDidChangeExtensionsStatus = this._onDidChangeExtensionsStatus.event; - private readonly _onDidChangeExtensions = this._register(new Emitter<{ readonly added: ReadonlyArray; readonly removed: ReadonlyArray }>({ leakWarningThreshold: 400 })); + private readonly _onDidChangeExtensions = this._register(new Emitter<{ readonly added: ReadonlyArray; readonly removed: ReadonlyArray }>({ leakWarningThreshold: 400, leakWarningName: 'ExtensionService._onDidChangeExtensions' })); public readonly onDidChangeExtensions = this._onDidChangeExtensions.event; private readonly _onWillActivateByEvent = this._register(new Emitter()); diff --git a/src/vs/workbench/services/label/common/labelService.ts b/src/vs/workbench/services/label/common/labelService.ts index a25505387fcdc..f524641081ab2 100644 --- a/src/vs/workbench/services/label/common/labelService.ts +++ b/src/vs/workbench/services/label/common/labelService.ts @@ -135,7 +135,7 @@ export class LabelService extends Disposable implements ILabelService { private formatters: ResourceLabelFormatter[]; - private readonly _onDidChangeFormatters = this._register(new Emitter({ leakWarningThreshold: 400 })); + private readonly _onDidChangeFormatters = this._register(new Emitter({ leakWarningThreshold: 400, leakWarningName: 'LabelService._onDidChangeFormatters' })); readonly onDidChangeFormatters = this._onDidChangeFormatters.event; private readonly storedFormattersMemento: Memento; diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts index 81a2ae95035ab..6c1300e0006a9 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts @@ -39,7 +39,7 @@ interface ITextFileEditorModelToRestore { export class TextFileEditorModelManager extends Disposable implements ITextFileEditorModelManager { - private readonly _onDidCreate = this._register(new Emitter({ leakWarningThreshold: 500 /* increased for users with hundreds of inputs opened */ })); + private readonly _onDidCreate = this._register(new Emitter({ leakWarningThreshold: 500, leakWarningName: 'TextFileEditorModelManager._onDidCreate' /* increased for users with hundreds of inputs opened */ })); readonly onDidCreate = this._onDidCreate.event; private readonly _onDidResolve = this._register(new Emitter()); diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index 797fc5441934f..99405e3d8552a 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -123,14 +123,14 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme this.colorThemeRegistry = this._register(new ThemeRegistry(colorThemesExtPoint, ColorThemeData.fromExtensionTheme)); this.colorThemeWatcher = this._register(new ThemeFileWatcher(fileService, environmentService, this.reloadCurrentColorTheme.bind(this))); - this.onColorThemeChange = this._register(new Emitter({ leakWarningThreshold: 400 })); + this.onColorThemeChange = this._register(new Emitter({ leakWarningThreshold: 400, leakWarningName: 'ThemeService.onColorThemeChange' })); this.currentColorTheme = ColorThemeData.createUnloadedTheme(''); this.colorThemeSequencer = new Sequencer(); this.fileIconThemeWatcher = this._register(new ThemeFileWatcher(fileService, environmentService, this.reloadCurrentFileIconTheme.bind(this))); this.fileIconThemeRegistry = this._register(new ThemeRegistry(fileIconThemesExtPoint, FileIconThemeData.fromExtensionTheme, true, FileIconThemeData.noIconTheme)); this.fileIconThemeLoader = new FileIconThemeLoader(extensionResourceLoaderService, languageService); - this.onFileIconThemeChange = this._register(new Emitter({ leakWarningThreshold: 400 })); + this.onFileIconThemeChange = this._register(new Emitter({ leakWarningThreshold: 400, leakWarningName: 'ThemeService.onFileIconThemeChange' })); this.currentFileIconTheme = FileIconThemeData.createUnloadedTheme(''); this.fileIconThemeSequencer = new Sequencer(); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 5b6867170cbad..4c06830a7f574 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -260,6 +260,8 @@ import './contrib/git/browser/git.contributions.js'; // SCM import './contrib/scm/browser/scm.contribution.js'; +import './contrib/scm/browser/quickDiff.contribution.js'; +import './contrib/scm/browser/scm.service.contribution.js'; // Debug import './contrib/debug/browser/debug.contribution.js'; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index f38544f604c98..40babc9343360 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -453,6 +453,11 @@ declare module 'vscode' { constructor(value: string | MarkdownString); } + export class ChatResponseInfoPart { + value: MarkdownString; + constructor(value: string | MarkdownString); + } + export class ChatResponseProgressPart2 extends ChatResponseProgressPart { value: string; task?: (progress: Progress) => Thenable; @@ -633,6 +638,15 @@ declare module 'vscode' { */ warning(message: string | MarkdownString): void; + /** + * Push an info banner to this stream. Short-hand for + * `push(new ChatResponseInfoPart(message))`. + * + * @param message An informational message + * @returns This stream. + */ + info(message: string | MarkdownString): void; + reference(value: Uri | Location | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }): void; reference2(value: Uri | Location | string | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }, options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }): void; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 58e75115de047..d3240d451a0af 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -100,11 +100,6 @@ declare module 'vscode' { readonly command?: string; }; - /** - * @deprecated Use `inputState` instead - */ - readonly sessionOptions: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; - readonly inputState: ChatSessionInputState; } @@ -505,11 +500,6 @@ declare module 'vscode' { */ provideChatSessionContent(resource: Uri, token: CancellationToken, context: { readonly inputState: ChatSessionInputState; - - /** - * @deprecated Use `inputState` instead - */ - readonly sessionOptions: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; }): Thenable | ChatSession; /** @@ -621,6 +611,16 @@ declare module 'vscode' { * Only one item per option group should be marked as default. */ readonly default?: boolean; + + /** + * Optional slash-command alias (without leading `/`) that selects this option + * when the user submits `/`. Does not send a chat request; only + * updates the selection so the next prompt runs with this option active. + * + * Scoped to chat sessions owned by the contributing provider. Names must be + * unique across the provider's groups; on conflict, the first declared wins. + */ + readonly slashCommand?: string; } /** @@ -678,6 +678,22 @@ declare module 'vscode' { * `{ inputState: ChatSessionInputState; sessionResource: Uri | undefined }` that they can use to determine which session and options they are being invoked for. */ readonly commands?: Command[]; + + /** + * Optional kind that hints how this option group should be presented in the UI. + * + * - `'permissions'`: The group represents tool-approval permissions for the session. + * The editor will not render this group as its own picker. Instead, its items + * replace the built-in items in the chat permission picker for the session, + * and the user's selection is reported back through the standard + * {@link ChatSessionContentProvider.handleChatSessionOptionsChange} flow. + * At most one option group per provider may use this kind; if more than one is + * declared, the first one (in declaration order) is used. The group is invisible + * if the chat permission picker itself is hidden by other `when` clauses. + * + * When omitted, the group is rendered as a standalone picker as usual. + */ + readonly kind?: 'permissions'; } export interface ChatSessionProviderOptions {